@wpmoo/toolkit 0.9.5 → 0.9.7

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,9 +1,11 @@
1
+ import { listEnvironmentDatabases, normalizeDatabaseListResult, } from '../databases.js';
1
2
  import { listModulesInSourceRepo } from '../module-actions.js';
2
3
  import { listModuleRepos } from '../repo-actions.js';
3
4
  import { listSources } from '../source-actions.js';
4
5
  import { handlePromptCancel, menuPromptMessage, } from '../menu-navigation.js';
5
6
  import { isPromptCancel, selectPrompt, textPrompt } from '../prompts/index.js';
6
7
  const manualModuleValue = '__wpmoo_manual_module_entry__';
8
+ const manualDatabaseValue = '__wpmoo_manual_database_entry__';
7
9
  function defaultCancelHandler(value, action) {
8
10
  handlePromptCancel(isPromptCancel(value), action);
9
11
  }
@@ -12,6 +14,7 @@ function promptDeps(deps = {}) {
12
14
  select: deps.select ?? ((options) => selectPrompt(options)),
13
15
  text: deps.text ?? ((options) => textPrompt(options)),
14
16
  list: deps.list ?? ((options) => selectPrompt(options)),
17
+ databases: deps.databases ?? ((cwd, options) => listEnvironmentDatabases(cwd, options)),
15
18
  handleCancel: deps.handleCancel ?? defaultCancelHandler,
16
19
  };
17
20
  }
@@ -80,6 +83,25 @@ async function optionalTextArg(deps, message, fallback) {
80
83
  placeholder: fallback,
81
84
  }), fallback, deps);
82
85
  }
86
+ async function databaseArg(cwd, deps, message, fallback, options = {}) {
87
+ const databaseResult = normalizeDatabaseListResult(await deps.databases(cwd, options));
88
+ const databases = databaseResult.databases;
89
+ if (databases.length > 0) {
90
+ const selected = await deps.list({
91
+ message: menuPromptMessage(message, 'back'),
92
+ options: [
93
+ ...databases.map((database) => ({ value: database, label: database })),
94
+ { value: manualDatabaseValue, label: 'Manual entry' },
95
+ ],
96
+ initialValue: databases.includes(fallback) ? fallback : databases[0],
97
+ });
98
+ deps.handleCancel(selected, 'back');
99
+ if (selected !== manualDatabaseValue) {
100
+ return String(selected);
101
+ }
102
+ }
103
+ return optionalTextArg(deps, databaseResult.ok ? message : `${message} (database list unavailable; enter manually)`, fallback);
104
+ }
83
105
  async function optionalModules(cwd, deps) {
84
106
  const modules = await detectedModules(cwd);
85
107
  if (modules.length === 0) {
@@ -120,19 +142,16 @@ export async function collectDailyActionArgs(command, cwd, promptDepsArg = {}) {
120
142
  return [await optionalTextArg(deps, 'Service', 'odoo')];
121
143
  }
122
144
  if (command === 'psql') {
123
- return [await optionalTextArg(deps, 'Database', 'postgres')];
145
+ return [await databaseArg(cwd, deps, 'Database', 'postgres', { includeMaintenance: true })];
124
146
  }
125
147
  if (command === 'install' || command === 'update') {
126
148
  const modules = await moduleArg(cwd, deps);
127
- const db = asString(await deps.text({
128
- message: menuPromptMessage('Database (optional)', 'back'),
129
- placeholder: 'devel',
130
- }), '', deps);
131
- return db ? [modules, db] : [modules];
149
+ const db = await databaseArg(cwd, deps, 'Odoo database', 'devel');
150
+ return [modules, db];
132
151
  }
133
152
  if (command === 'test') {
134
153
  const modules = await moduleArg(cwd, deps);
135
- const db = await optionalTextArg(deps, 'Database', 'devel');
154
+ const db = await databaseArg(cwd, deps, 'Odoo database', 'devel');
136
155
  const mode = asString(await deps.list({
137
156
  message: menuPromptMessage('Mode', 'back'),
138
157
  options: [
@@ -151,17 +170,17 @@ export async function collectDailyActionArgs(command, cwd, promptDepsArg = {}) {
151
170
  }
152
171
  if (command === 'pot') {
153
172
  const modules = await moduleArg(cwd, deps);
154
- const db = await optionalTextArg(deps, 'Database', 'devel');
173
+ const db = await databaseArg(cwd, deps, 'Odoo database', 'devel');
155
174
  const output = await optionalTextArg(deps, 'Output file', `i18n/${modules}.pot`);
156
175
  return [modules, db, output];
157
176
  }
158
177
  if (command === 'resetdb') {
159
- const db = await optionalTextArg(deps, 'Database', 'devel');
178
+ const db = await databaseArg(cwd, deps, 'Odoo database', 'devel');
160
179
  const modules = await optionalModules(cwd, deps);
161
180
  return modules ? [db, modules] : [db];
162
181
  }
163
182
  if (command === 'snapshot') {
164
- const db = await optionalTextArg(deps, 'Database', 'devel');
183
+ const db = await databaseArg(cwd, deps, 'Odoo database', 'devel');
165
184
  const snapshotName = await optionalTextArg(deps, 'Snapshot name', 'before-update');
166
185
  return [db, snapshotName];
167
186
  }
@@ -170,7 +189,7 @@ export async function collectDailyActionArgs(command, cwd, promptDepsArg = {}) {
170
189
  message: menuPromptMessage('Snapshot name', 'back'),
171
190
  validate: (value) => (value.trim() ? undefined : 'Enter the snapshot name.'),
172
191
  }), 'Snapshot name is required.', deps);
173
- const db = await optionalTextArg(deps, 'Database', 'devel');
192
+ const db = await databaseArg(cwd, deps, 'Odoo database', 'devel');
174
193
  return [snapshotName, db];
175
194
  }
176
195
  return [];
@@ -116,6 +116,7 @@ function defaultCommand(serviceStatus) {
116
116
  export async function selectCockpitTopLevelMenu(options = {}) {
117
117
  const deps = menuDeps(options);
118
118
  const choices = topLevelChoices(options.serviceStatus);
119
+ const cancelAction = 'back';
119
120
  const selected = await deps.select({
120
121
  message: '',
121
122
  choices: [...choices],
@@ -124,8 +125,10 @@ export async function selectCockpitTopLevelMenu(options = {}) {
124
125
  loop: false,
125
126
  hideMessage: true,
126
127
  disabledError: disabledError(options.serviceStatus),
128
+ navigationWarning: options.navigationWarning,
129
+ escapeBehavior: 'ignore',
127
130
  });
128
- deps.handleCancel(selected, 'exit');
131
+ deps.handleCancel(selected, cancelAction);
129
132
  if (selected === 'exit') {
130
133
  return { kind: 'exit' };
131
134
  }
@@ -0,0 +1,40 @@
1
+ import { handlePromptCancel, } from '../menu-navigation.js';
2
+ import { isPromptCancel, selectPrompt, } from '../prompts/index.js';
3
+ const moduleActions = [
4
+ { id: 'delete', label: 'Delete module' },
5
+ { id: 'update', label: 'Update' },
6
+ { id: 'test', label: 'Test' },
7
+ { id: 'lint', label: 'Run environment lint' },
8
+ ];
9
+ function defaultCancelHandler(value, action) {
10
+ handlePromptCancel(isPromptCancel(value), action);
11
+ }
12
+ function deps(options = {}) {
13
+ return {
14
+ select: options.select ?? ((options) => selectPrompt(options)),
15
+ handleCancel: options.handleCancel ?? defaultCancelHandler,
16
+ };
17
+ }
18
+ export function moduleActionChoices() {
19
+ return moduleActions.map(({ id, label }) => ({ value: id, name: label }));
20
+ }
21
+ function isModuleAction(value) {
22
+ return typeof value === 'string' && moduleActions.some((action) => action.id === value);
23
+ }
24
+ export async function selectModuleAction(module, options = {}) {
25
+ const promptDeps = deps(options);
26
+ const cancelAction = options.cancelAction ?? 'back';
27
+ const selected = await promptDeps.select({
28
+ message: `Module: ${module.moduleName}`,
29
+ choices: moduleActionChoices(),
30
+ default: 'update',
31
+ loop: false,
32
+ hideMessage: true,
33
+ navigationHelp: cancelAction === 'back' ? 'back' : 'exit',
34
+ });
35
+ promptDeps.handleCancel(selected, cancelAction);
36
+ if (isModuleAction(selected)) {
37
+ return selected;
38
+ }
39
+ return undefined;
40
+ }
@@ -0,0 +1,117 @@
1
+ import { styleText } from 'node:util';
2
+ import { listModulesInEnvironment, } from '../module-actions.js';
3
+ import { handlePromptCancel, } from '../menu-navigation.js';
4
+ import { isPromptCancel, promptSeparator, selectPrompt, } from '../prompts/index.js';
5
+ const sourceTypeLabels = {
6
+ private: 'Private',
7
+ oca: 'OCA',
8
+ external: 'External',
9
+ };
10
+ const sourceTypeOrder = ['private', 'oca', 'external'];
11
+ const minimumPageSize = 8;
12
+ const reservedRows = 7;
13
+ function rgb(red, green, blue, value) {
14
+ return `\u001B[38;2;${red};${green};${blue}m${value}\u001B[39m`;
15
+ }
16
+ function dim(value) {
17
+ return styleText('dim', value, { validateStream: false });
18
+ }
19
+ function categoryHeading(label) {
20
+ return `\u001B[1D${rgb(143, 211, 255, label)}`;
21
+ }
22
+ function repositoryHeading(repoLabel, repoContext, width) {
23
+ return `\u001B[1D${rgb(143, 211, 255, `📁 ${repoLabel.padEnd(width)}`)}${dim(` ${repoContext}`)}`;
24
+ }
25
+ function repositoryContext(module) {
26
+ return module.repoSlug ?? module.repoPath;
27
+ }
28
+ function sourceContext(module) {
29
+ return `${module.sourceType}/${module.repoPath}`;
30
+ }
31
+ export function renderModuleDetails(module) {
32
+ return [
33
+ `Name: ${module.moduleName}`,
34
+ `Source: ${sourceContext(module)}`,
35
+ `Path: odoo/custom/src/${module.sourceType}/${module.repoPath}/${module.moduleName}`,
36
+ ].join('\n');
37
+ }
38
+ function moduleChoiceName(module, width) {
39
+ return `${rgb(226, 184, 96, ` ${module.moduleName.padEnd(width)}`)}${dim(` ${sourceContext(module)}`)}`;
40
+ }
41
+ function pageSize(choiceCount) {
42
+ const terminalRows = process.stdout.rows;
43
+ if (!terminalRows || terminalRows <= 0) {
44
+ return Math.min(choiceCount, 12);
45
+ }
46
+ return Math.min(choiceCount, Math.max(minimumPageSize, terminalRows - reservedRows));
47
+ }
48
+ function defaultCancelHandler(value, action) {
49
+ handlePromptCancel(isPromptCancel(value), action);
50
+ }
51
+ function deps(options = {}) {
52
+ return {
53
+ select: options.select ?? ((selectOptions) => selectPrompt(selectOptions)),
54
+ handleCancel: options.handleCancel ?? defaultCancelHandler,
55
+ };
56
+ }
57
+ export function moduleBrowserChoices(modules) {
58
+ const moduleWidth = Math.max(...modules.map((module) => module.moduleName.length), 1);
59
+ const repositoryWidth = Math.max(...modules.map((module) => module.repoPath.length), 1);
60
+ const choices = [];
61
+ for (const sourceType of sourceTypeOrder) {
62
+ const sourceModules = modules
63
+ .filter((module) => module.sourceType === sourceType)
64
+ .sort((left, right) => left.repoPath.localeCompare(right.repoPath) || left.moduleName.localeCompare(right.moduleName));
65
+ if (sourceModules.length === 0) {
66
+ continue;
67
+ }
68
+ if (choices.length > 0) {
69
+ choices.push(promptSeparator(' '));
70
+ }
71
+ choices.push(promptSeparator(categoryHeading(sourceTypeLabels[sourceType])));
72
+ const modulesByRepo = new Map();
73
+ for (const module of sourceModules) {
74
+ const bucket = modulesByRepo.get(module.repoPath);
75
+ if (bucket) {
76
+ bucket.push(module);
77
+ }
78
+ else {
79
+ modulesByRepo.set(module.repoPath, [module]);
80
+ }
81
+ }
82
+ for (const [repoPath, repoModules] of modulesByRepo) {
83
+ const sortedRepoModules = [...repoModules].sort((left, right) => left.moduleName.localeCompare(right.moduleName));
84
+ const headingLabel = repositoryHeading(repoPath, repositoryContext(sortedRepoModules[0]), repositoryWidth);
85
+ choices.push(promptSeparator(headingLabel));
86
+ choices.push(...sortedRepoModules.map((module) => ({
87
+ value: module,
88
+ name: moduleChoiceName(module, moduleWidth),
89
+ short: module.moduleName,
90
+ })));
91
+ }
92
+ }
93
+ return choices;
94
+ }
95
+ export async function selectModuleFromBrowser(target, options = {}) {
96
+ const modules = await listModulesInEnvironment(target);
97
+ if (modules.length === 0) {
98
+ return undefined;
99
+ }
100
+ const moduleChoices = moduleBrowserChoices(modules);
101
+ const promptDeps = deps(options);
102
+ const cancelAction = options.cancelAction ?? 'back';
103
+ const selected = await promptDeps.select({
104
+ message: '',
105
+ choices: moduleChoices,
106
+ default: modules[0],
107
+ pageSize: pageSize(moduleChoices.length),
108
+ loop: false,
109
+ hideMessage: true,
110
+ navigationHelp: cancelAction === 'back' ? 'back' : 'exit',
111
+ });
112
+ promptDeps.handleCancel(selected, cancelAction);
113
+ if (typeof selected === 'object' && selected !== null && 'moduleName' in selected) {
114
+ return selected;
115
+ }
116
+ return undefined;
117
+ }
@@ -18,6 +18,10 @@ export const dailyActionCommands = [
18
18
  'lint',
19
19
  'pot',
20
20
  ];
21
+ const ANSI_DIM_INFO = '\u001B[2m\u001B[38;2;120;157;181m';
22
+ const ANSI_WARNING = '\u001B[33m';
23
+ const ANSI_DEFAULT_FOREGROUND = '\u001B[39m';
24
+ const ANSI_RESET = '\u001B[0m';
21
25
  const dailyActionCommandSet = new Set(dailyActionCommands);
22
26
  export const dailyActionScripts = {
23
27
  start: 'up.sh',
@@ -56,7 +60,7 @@ function usage(command) {
56
60
  if (command === 'update')
57
61
  return 'Usage: wpmoo update <module[,module]> [db]';
58
62
  if (command === 'test')
59
- return 'Usage: wpmoo test <module[,module]> [--db <db>] [--mode init|update] [--tags <tags>]';
63
+ return 'Usage: wpmoo test <module[,module]> [--db <db>] [--mode auto|init|update] [--tags <tags>]';
60
64
  if (command === 'resetdb')
61
65
  return 'Usage: wpmoo resetdb [db] [module[,module]]';
62
66
  if (command === 'snapshot')
@@ -111,8 +115,8 @@ function testArgs(argv) {
111
115
  const value = rest[index + 1];
112
116
  if (!value || value.startsWith('--'))
113
117
  throw new Error(`Missing value for ${option}`);
114
- if (option === '--mode' && value !== 'init' && value !== 'update') {
115
- throw new Error('Invalid value for --mode: expected init or update');
118
+ if (option === '--mode' && value !== 'auto' && value !== 'init' && value !== 'update') {
119
+ throw new Error('Invalid value for --mode: expected auto, init, or update');
116
120
  }
117
121
  index += 1;
118
122
  }
@@ -185,6 +189,39 @@ async function spawnDailyAction(plan) {
185
189
  throw new Error(`Daily action script exited with code ${exitCode ?? 'unknown'}: ${plan.scriptPath}`);
186
190
  }
187
191
  }
192
+ function renderDailyActionOutputLine(line) {
193
+ if (line.startsWith('WARNING:')) {
194
+ return `${ANSI_WARNING}WARNING:${ANSI_DEFAULT_FOREGROUND}${ANSI_DIM_INFO}${line.slice('WARNING:'.length)}${ANSI_RESET}`;
195
+ }
196
+ if (line === "Running as user 'root' is a security risk.") {
197
+ return `${ANSI_DIM_INFO}${line}${ANSI_RESET}`;
198
+ }
199
+ return line;
200
+ }
201
+ export function renderDailyActionOutput(output) {
202
+ return output
203
+ .split(/(\r?\n)/u)
204
+ .map((part) => (part === '\n' || part === '\r\n' ? part : renderDailyActionOutputLine(part)))
205
+ .join('');
206
+ }
207
+ async function spawnDailyActionWithStyledOutput(plan, writer) {
208
+ const child = spawn(plan.scriptPath, plan.args, {
209
+ cwd: plan.cwd,
210
+ stdio: ['inherit', 'pipe', 'pipe'],
211
+ });
212
+ child.stdout?.on('data', (chunk) => writer(renderDailyActionOutput(chunk.toString('utf8'))));
213
+ child.stderr?.on('data', (chunk) => writer(renderDailyActionOutput(chunk.toString('utf8'))));
214
+ const exitCode = await new Promise((resolve, reject) => {
215
+ child.on('error', reject);
216
+ child.on('close', resolve);
217
+ });
218
+ if (exitCode !== 0) {
219
+ throw new Error(`Daily action script exited with code ${exitCode ?? 'unknown'}: ${plan.scriptPath}`);
220
+ }
221
+ }
188
222
  export async function runDailyAction(command, argv, cwd = process.cwd(), runner = spawnDailyAction) {
189
223
  await runner(await dailyActionPlan(command, argv, cwd));
190
224
  }
225
+ export async function runDailyActionWithStyledOutput(command, argv, cwd = process.cwd(), writer = (chunk) => process.stdout.write(chunk)) {
226
+ await spawnDailyActionWithStyledOutput(await dailyActionPlan(command, argv, cwd), writer);
227
+ }
@@ -0,0 +1,58 @@
1
+ import { spawn } from 'node:child_process';
2
+ const maintenanceDatabases = new Set(['postgres']);
3
+ const listDatabasesQuery = [
4
+ 'SELECT datname',
5
+ 'FROM pg_database',
6
+ 'WHERE datistemplate = false',
7
+ "ORDER BY CASE WHEN datname = 'devel' THEN 0 WHEN datname = current_database() THEN 1 ELSE 2 END, datname;",
8
+ ].join(' ');
9
+ export function parseDatabaseListOutput(output, options = {}) {
10
+ const seen = new Set();
11
+ const databases = [];
12
+ for (const line of output.split(/\r?\n/u)) {
13
+ const database = line.trim();
14
+ if (!/^[A-Za-z0-9_.-]+$/u.test(database) ||
15
+ database.startsWith('-') ||
16
+ seen.has(database) ||
17
+ (!options.includeMaintenance && maintenanceDatabases.has(database))) {
18
+ continue;
19
+ }
20
+ seen.add(database);
21
+ databases.push(database);
22
+ }
23
+ return databases;
24
+ }
25
+ export function normalizeDatabaseListResult(result) {
26
+ if (Array.isArray(result)) {
27
+ return { ok: true, databases: result };
28
+ }
29
+ return result;
30
+ }
31
+ export async function listEnvironmentDatabases(cwd, options = {}) {
32
+ const queryLiteral = JSON.stringify(listDatabasesQuery);
33
+ const command = [
34
+ `query=${queryLiteral}`,
35
+ '. ./scripts/lib.sh >/dev/null',
36
+ 'compose exec -T db psql -U "${POSTGRES_USER:-odoo}" -d "${POSTGRES_DB:-postgres}" -Atc "$query"',
37
+ ].join(' && ');
38
+ return new Promise((resolve) => {
39
+ const child = spawn('bash', ['-lc', command], {
40
+ cwd,
41
+ stdio: ['ignore', 'pipe', 'pipe'],
42
+ });
43
+ let output = '';
44
+ let errorOutput = '';
45
+ child.stdout?.on('data', (chunk) => {
46
+ output += chunk.toString('utf8');
47
+ });
48
+ child.stderr?.on('data', (chunk) => {
49
+ errorOutput += chunk.toString('utf8');
50
+ });
51
+ child.on('error', (error) => resolve({ ok: false, databases: [], error: error.message }));
52
+ child.on('close', (code) => {
53
+ resolve(code === 0
54
+ ? { ok: true, databases: parseDatabaseListOutput(output, options) }
55
+ : { ok: false, databases: [], error: errorOutput.trim() || `Database list command exited with ${code}` });
56
+ });
57
+ });
58
+ }
package/dist/help.js CHANGED
@@ -30,7 +30,7 @@ Usage:
30
30
  npx @wpmoo/toolkit psql [db]
31
31
  npx @wpmoo/toolkit install <module[,module]> [db]
32
32
  npx @wpmoo/toolkit update <module[,module]> [db]
33
- npx @wpmoo/toolkit test <module[,module]> [--db <db>] [--mode init|update] [--tags <tags>]
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
35
  npx @wpmoo/toolkit snapshot [db] [snapshot-name]
36
36
  npx @wpmoo/toolkit restore-snapshot [--dry-run] <snapshot-name> [db]
@@ -91,6 +91,8 @@ Cockpit:
91
91
 
92
92
  Wizard local-only path:
93
93
  Run npx @wpmoo/toolkit from a workspace directory to open the create wizard.
94
+ Before setup starts, WPMoo checks Git, Docker, Docker Compose, and Docker Engine.
95
+ If required tools are missing, WPMoo offers installer guidance before writing files.
94
96
  Choose any environment folder; the default is ./<product>_dev.
95
97
  Skip Git/GitHub connection to create a local-only environment.
96
98
  Add source repos later from the cockpit or with add-repo.
@@ -118,7 +120,7 @@ Task recipes:
118
120
  Add OCA module:
119
121
  npx @wpmoo/toolkit add-module --repo sale-workflow --module sale_order_line_no_discount --source-type oca
120
122
  Run tests:
121
- npx @wpmoo/toolkit test <module[,module]> [--db <db>] [--mode init|update] [--tags <tags>]
123
+ npx @wpmoo/toolkit test <module[,module]> [--db <db>] [--mode auto|init|update] [--tags <tags>]
122
124
  Safe reset and recover:
123
125
  npx @wpmoo/toolkit snapshot [db] [snapshot-name]
124
126
  npx @wpmoo/toolkit reset --dry-run
@@ -10,10 +10,10 @@ export function isMenuBackSignal(error) {
10
10
  return error instanceof MenuBackSignal;
11
11
  }
12
12
  export function menuIntroTitle(title, action) {
13
- return action === 'back' ? `${title} · Back (Esc)` : title;
13
+ return title;
14
14
  }
15
15
  export function menuPromptMessage(message, action) {
16
- return action === 'back' ? `${message} · Esc to go back` : message;
16
+ return message;
17
17
  }
18
18
  export function promptCancelOutcome(cancelled, action, key) {
19
19
  if (!cancelled) {
@@ -4,8 +4,27 @@ import { addModuleToSourceRepoInAddonsYaml, removeModuleFromSourceRepoInAddonsYa
4
4
  import { readEnvironmentMetadata } from './environment.js';
5
5
  import { realGit, stageAll } from './git.js';
6
6
  import { pathUnderBase, validateModuleName, validateRepoPath } from './path-validation.js';
7
- import { readAddonsYaml, writeAddonsYaml } from './repo-actions.js';
7
+ import { listModuleRepos, readAddonsYaml, writeAddonsYaml } from './repo-actions.js';
8
+ import { listSources } from './source-actions.js';
9
+ const sourceTypeSortOrder = ['private', 'oca', 'external'];
10
+ const githubRepoUrlPattern = /^(?:https?:\/\/|git@)github\.com[/:]([^/]+)\/([^/.#?]+)(?:\.git)?(?:[/?#].*)?$/i;
8
11
  const validSourceTypes = ['private', 'oca', 'external'];
12
+ function deriveRepoSlug(repoUrl) {
13
+ if (!repoUrl) {
14
+ return undefined;
15
+ }
16
+ const normalized = repoUrl.trim().replace(/[?#].*$/, '');
17
+ const match = githubRepoUrlPattern.exec(normalized);
18
+ if (!match) {
19
+ return undefined;
20
+ }
21
+ const owner = match[1]?.trim();
22
+ const repo = match[2]?.trim();
23
+ if (!owner || !repo) {
24
+ return undefined;
25
+ }
26
+ return `${owner}/${repo}`;
27
+ }
9
28
  function normalizeSourceType(value) {
10
29
  return validSourceTypes.includes(value) ? value : 'private';
11
30
  }
@@ -94,6 +113,36 @@ export async function listModulesInSourceRepo(target, repoPath, sourceType) {
94
113
  return [];
95
114
  }
96
115
  }
116
+ export async function listModulesInEnvironment(target) {
117
+ const sources = await listSources(target);
118
+ const sourceRepos = sources.length > 0
119
+ ? sources.map((source) => ({
120
+ repoPath: source.path,
121
+ sourceType: source.type,
122
+ repoUrl: source.url,
123
+ }))
124
+ : (await listModuleRepos(target)).map((repoPath) => ({ repoPath, sourceType: 'private' }));
125
+ const listedModules = await Promise.all(sourceRepos.map(async ({ repoPath, sourceType, repoUrl }) => {
126
+ try {
127
+ const moduleNames = await listModulesInSourceRepo(target, repoPath, sourceType);
128
+ const repoSlug = deriveRepoSlug(repoUrl);
129
+ return moduleNames.map((moduleName) => ({
130
+ moduleName,
131
+ repoPath,
132
+ sourceType,
133
+ ...(repoUrl ? { repoUrl } : {}),
134
+ ...(repoSlug ? { repoSlug } : {}),
135
+ }));
136
+ }
137
+ catch {
138
+ return [];
139
+ }
140
+ }));
141
+ const sourceTypeOrder = new Map(sourceTypeSortOrder.map((sourceType, index) => [sourceType, index]));
142
+ return listedModules.flat().sort((left, right) => (sourceTypeOrder.get(left.sourceType) ?? 0) - (sourceTypeOrder.get(right.sourceType) ?? 0) ||
143
+ left.repoPath.localeCompare(right.repoPath) ||
144
+ left.moduleName.localeCompare(right.moduleName));
145
+ }
97
146
  export async function removeModuleFromSourceRepo(options, git = realGit) {
98
147
  const repoPath = validateRepoPath(options.repoPath);
99
148
  const moduleName = validateModuleName(options.moduleName);
@@ -3,7 +3,7 @@ import { styleText } from 'node:util';
3
3
  import inquirerSelect, { Separator as InquirerSeparator } from '@inquirer/select';
4
4
  import inquirerSearch from '@inquirer/search';
5
5
  import { confirm as inquirerConfirm, input as inquirerInput } from '@inquirer/prompts';
6
- import { recordPromptCancelKey } from '../menu-navigation.js';
6
+ import { consumePromptCancelKey, recordPromptCancelKey } from '../menu-navigation.js';
7
7
  export const promptCancelled = Symbol.for('wpmoo.prompt.cancelled');
8
8
  export function promptSeparator(label) {
9
9
  return new InquirerSeparator(label);
@@ -46,10 +46,38 @@ function asInquirerSearchConfig(options) {
46
46
  pageSize: options.pageSize,
47
47
  };
48
48
  }
49
- function installEscapeAbortController(controller) {
49
+ function isEscapeKey(key) {
50
+ if (typeof key !== 'object' || key === null) {
51
+ return false;
52
+ }
53
+ const candidate = key;
54
+ return candidate.name === 'escape' || candidate.sequence === '\u001B';
55
+ }
56
+ function installIgnoredEscapeFilter(options) {
50
57
  emitKeypressEvents(process.stdin);
58
+ const input = process.stdin;
59
+ const originalEmit = input.emit;
60
+ const patchedEmit = function patchedEmit(eventName, ...args) {
61
+ if (eventName === 'keypress' && isEscapeKey(args[1])) {
62
+ consumePromptCancelKey();
63
+ return true;
64
+ }
65
+ return Reflect.apply(originalEmit, this, [eventName, ...args]);
66
+ };
67
+ input.emit = patchedEmit;
68
+ return () => {
69
+ if (input.emit === patchedEmit) {
70
+ input.emit = originalEmit;
71
+ }
72
+ };
73
+ }
74
+ function installEscapeAbortController(controller, options = {}) {
75
+ emitKeypressEvents(process.stdin);
76
+ if (options.escapeBehavior === 'ignore') {
77
+ return installIgnoredEscapeFilter(options);
78
+ }
51
79
  const listener = (_value, key) => {
52
- if (key.name !== 'escape' && key.sequence !== '\u001B') {
80
+ if (!isEscapeKey(key)) {
53
81
  return;
54
82
  }
55
83
  recordPromptCancelKey(key);
@@ -60,9 +88,9 @@ function installEscapeAbortController(controller) {
60
88
  process.stdin.on('keypress', listener);
61
89
  return () => process.stdin.off('keypress', listener);
62
90
  }
63
- async function withPromptCancelGuard(callback) {
91
+ async function withPromptCancelGuard(callback, options = {}) {
64
92
  const controller = new AbortController();
65
- const removeEscapeListener = installEscapeAbortController(controller);
93
+ const removeEscapeListener = installEscapeAbortController(controller, options);
66
94
  try {
67
95
  return await callback({ signal: controller.signal });
68
96
  }
@@ -92,32 +120,52 @@ function asInquirerSelectConfig(options) {
92
120
  loop: options.loop,
93
121
  hideMessage: options.hideMessage,
94
122
  disabledError: options.disabledError,
123
+ navigationHelp: options.navigationHelp,
124
+ navigationWarning: options.navigationWarning,
125
+ escapeBehavior: options.escapeBehavior,
95
126
  };
96
127
  }
97
- function hiddenSelectTheme(disabledError) {
128
+ function renderedNavigationWarning(navigationWarning) {
129
+ const warning = typeof navigationWarning === 'function' ? navigationWarning() : navigationWarning;
130
+ return warning ? `\u001B[2m\u001B[38;2;226;184;96m${warning}\u001B[0m` : undefined;
131
+ }
132
+ function hiddenSelectTheme(disabledError, navigationHelp = 'exit', navigationWarning, hideMessage = true) {
133
+ const keysHelpTip = navigationHelp === 'back'
134
+ ? '↑↓ navigate • ⏎ select • Esc to go back'
135
+ : '↑↓ navigate • ⏎ select • Ctrl+C exit';
136
+ const style = {
137
+ highlight: (text) => text,
138
+ disabled: (text) => styleText('dim', text.replace(/ \(disabled\)$/u, ''), { validateStream: false }),
139
+ keysHelpTip: () => {
140
+ const warning = renderedNavigationWarning(navigationWarning);
141
+ return warning ? `${warning}\n${keysHelpTip}` : keysHelpTip;
142
+ },
143
+ };
144
+ if (hideMessage) {
145
+ style.message = () => '';
146
+ }
98
147
  return {
99
148
  prefix: '',
100
149
  icon: {
101
150
  cursor: '\u001B[38;2;226;184;96m❯\u001B[39m',
102
151
  },
103
- style: {
104
- message: () => '',
105
- highlight: (text) => text,
106
- disabled: (text) => styleText('dim', text.replace(/ \(disabled\)$/u, ''), { validateStream: false }),
107
- keysHelpTip: () => '↑↓ navigate • ⏎ select • Ctrl+C exit',
108
- },
152
+ style,
109
153
  i18n: disabledError ? { disabledError } : undefined,
110
154
  };
111
155
  }
112
156
  function withHiddenSelectMessage(config) {
113
- if (!config.hideMessage) {
157
+ if (!config.hideMessage &&
158
+ !config.disabledError &&
159
+ !config.navigationHelp &&
160
+ !config.navigationWarning &&
161
+ !config.escapeBehavior) {
114
162
  return config;
115
163
  }
116
- const { disabledError, hideMessage: _hideMessage, ...inquirerConfig } = config;
164
+ const { disabledError, hideMessage: _hideMessage, navigationHelp, navigationWarning, escapeBehavior: _escapeBehavior, ...inquirerConfig } = config;
117
165
  return {
118
166
  ...inquirerConfig,
119
- message: '',
120
- theme: hiddenSelectTheme(disabledError),
167
+ message: config.hideMessage ? '' : inquirerConfig.message,
168
+ theme: hiddenSelectTheme(disabledError, navigationHelp, navigationWarning, Boolean(config.hideMessage)),
121
169
  };
122
170
  }
123
171
  function asInquirerConfirmConfig(options) {
@@ -143,10 +191,13 @@ export function isPromptCancel(value) {
143
191
  return value === promptCancelled;
144
192
  }
145
193
  export async function selectPrompt(options) {
194
+ const guardOptions = {
195
+ escapeBehavior: options.escapeBehavior,
196
+ };
146
197
  if (isClackSelectOptions(options)) {
147
- return withPromptCancelGuard((context) => inquirerSelect(withHiddenSelectMessage(asInquirerSelectConfig(options)), context));
198
+ return withPromptCancelGuard((context) => inquirerSelect(withHiddenSelectMessage(asInquirerSelectConfig(options)), context), guardOptions);
148
199
  }
149
- return withPromptCancelGuard((context) => inquirerSelect(withHiddenSelectMessage(options), context));
200
+ return withPromptCancelGuard((context) => inquirerSelect(withHiddenSelectMessage(options), context), guardOptions);
150
201
  }
151
202
  export async function inputPrompt(options) {
152
203
  return withPromptCancelGuard((context) => inquirerInput(asInquirerInputConfig(options), context));