@wpmoo/toolkit 0.9.4 → 0.9.6

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.
@@ -32,25 +32,51 @@ function categoryHeading(category) {
32
32
  function commandName(command) {
33
33
  return `${rgb(226, 184, 96, ` ${command.label.padEnd(topLevelCommandLabelWidth)}`)}${dim(` ${command.description}`)}`;
34
34
  }
35
- function categoryChoices(category, index) {
35
+ function disabledReason(command, serviceStatus) {
36
+ if (command.category !== 'services' || !serviceStatus)
37
+ return undefined;
38
+ if (serviceStatus.kind === 'docker-not-running')
39
+ return 'Docker not running.';
40
+ if (serviceStatus.kind === 'running' && command.id === 'start')
41
+ return 'Already running.';
42
+ if (serviceStatus.kind === 'stopped' && ['stop', 'restart', 'logs', 'shell'].includes(command.id)) {
43
+ return 'Services stopped.';
44
+ }
45
+ return undefined;
46
+ }
47
+ function disabledMenuReason(serviceStatus) {
48
+ if (serviceStatus?.kind === 'docker-not-running')
49
+ return 'Docker not running.';
50
+ if (serviceStatus?.kind === 'running')
51
+ return 'Already running.';
52
+ if (serviceStatus?.kind === 'stopped')
53
+ return 'Services stopped.';
54
+ return undefined;
55
+ }
56
+ function disabledError(serviceStatus) {
57
+ const reason = disabledMenuReason(serviceStatus);
58
+ return reason ? `This option is disabled and cannot be selected.\nReason: ${reason}` : undefined;
59
+ }
60
+ function categoryChoices(category, index, serviceStatus) {
36
61
  const choices = [
37
62
  promptSeparator(categoryHeading(category)),
38
63
  ...topLevelCommands
39
64
  .filter((command) => command.category === category)
40
- .map((command) => ({
41
- value: command,
42
- name: commandName(command),
43
- short: command.label,
44
- })),
65
+ .map((command) => {
66
+ const reason = disabledReason(command, serviceStatus);
67
+ return {
68
+ value: command,
69
+ name: commandName(command),
70
+ short: command.label,
71
+ disabled: reason ? true : undefined,
72
+ };
73
+ }),
45
74
  ];
46
75
  if (index < topLevelCategoryOrder.length - 1) {
47
76
  choices.push(promptSeparator(' '));
48
77
  }
49
78
  return choices;
50
79
  }
51
- const topLevelChoices = [
52
- ...topLevelCategoryOrder.flatMap(categoryChoices),
53
- ];
54
80
  const minimumTopLevelPageSize = 8;
55
81
  const startupViewportReservedRows = 11;
56
82
  function topLevelPageSize(choiceCount) {
@@ -75,17 +101,34 @@ function menuDeps(deps = {}) {
75
101
  function isCockpitCommand(value) {
76
102
  return typeof value === 'object' && value !== null && 'id' in value && 'slashAlias' in value;
77
103
  }
104
+ function topLevelChoices(serviceStatus) {
105
+ return topLevelCategoryOrder.flatMap((category, index) => categoryChoices(category, index, serviceStatus));
106
+ }
107
+ function defaultCommand(serviceStatus) {
108
+ if (serviceStatus?.kind === 'running') {
109
+ return cockpitCommands.find((command) => command.id === 'stop') ?? topLevelCommands[0];
110
+ }
111
+ if (serviceStatus?.kind === 'docker-not-running') {
112
+ return cockpitCommands.find((command) => command.id === 'status') ?? topLevelCommands[0];
113
+ }
114
+ return cockpitCommands.find((command) => command.id === 'start') ?? topLevelCommands[0];
115
+ }
78
116
  export async function selectCockpitTopLevelMenu(options = {}) {
79
117
  const deps = menuDeps(options);
118
+ const choices = topLevelChoices(options.serviceStatus);
119
+ const cancelAction = 'back';
80
120
  const selected = await deps.select({
81
121
  message: '',
82
- choices: [...topLevelChoices],
83
- default: topLevelCommands[0],
84
- pageSize: topLevelPageSize(topLevelChoices.length),
122
+ choices: [...choices],
123
+ default: defaultCommand(options.serviceStatus),
124
+ pageSize: topLevelPageSize(choices.length),
85
125
  loop: false,
86
126
  hideMessage: true,
127
+ disabledError: disabledError(options.serviceStatus),
128
+ navigationWarning: options.navigationWarning,
129
+ escapeBehavior: 'ignore',
87
130
  });
88
- deps.handleCancel(selected, 'exit');
131
+ deps.handleCancel(selected, cancelAction);
89
132
  if (selected === 'exit') {
90
133
  return { kind: 'exit' };
91
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: '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,46 @@
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 async function listEnvironmentDatabases(cwd, options = {}) {
26
+ const queryLiteral = JSON.stringify(listDatabasesQuery);
27
+ const command = [
28
+ `query=${queryLiteral}`,
29
+ '. ./scripts/lib.sh >/dev/null',
30
+ 'compose exec -T db psql -U "${POSTGRES_USER:-odoo}" -d "${POSTGRES_DB:-postgres}" -Atc "$query"',
31
+ ].join(' && ');
32
+ return new Promise((resolve) => {
33
+ const child = spawn('bash', ['-lc', command], {
34
+ cwd,
35
+ stdio: ['ignore', 'pipe', 'ignore'],
36
+ });
37
+ let output = '';
38
+ child.stdout?.on('data', (chunk) => {
39
+ output += chunk.toString('utf8');
40
+ });
41
+ child.on('error', () => resolve([]));
42
+ child.on('close', (code) => {
43
+ resolve(code === 0 ? parseDatabaseListOutput(output, options) : []);
44
+ });
45
+ });
46
+ }
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]
@@ -118,7 +118,7 @@ Task recipes:
118
118
  Add OCA module:
119
119
  npx @wpmoo/toolkit add-module --repo sale-workflow --module sale_order_line_no_discount --source-type oca
120
120
  Run tests:
121
- npx @wpmoo/toolkit test <module[,module]> [--db <db>] [--mode init|update] [--tags <tags>]
121
+ npx @wpmoo/toolkit test <module[,module]> [--db <db>] [--mode auto|init|update] [--tags <tags>]
122
122
  Safe reset and recover:
123
123
  npx @wpmoo/toolkit snapshot [db] [snapshot-name]
124
124
  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);
@@ -1,8 +1,9 @@
1
1
  import { emitKeypressEvents } from 'node:readline';
2
+ import { styleText } from 'node:util';
2
3
  import inquirerSelect, { Separator as InquirerSeparator } from '@inquirer/select';
3
4
  import inquirerSearch from '@inquirer/search';
4
5
  import { confirm as inquirerConfirm, input as inquirerInput } from '@inquirer/prompts';
5
- import { recordPromptCancelKey } from '../menu-navigation.js';
6
+ import { consumePromptCancelKey, recordPromptCancelKey } from '../menu-navigation.js';
6
7
  export const promptCancelled = Symbol.for('wpmoo.prompt.cancelled');
7
8
  export function promptSeparator(label) {
8
9
  return new InquirerSeparator(label);
@@ -45,10 +46,38 @@ function asInquirerSearchConfig(options) {
45
46
  pageSize: options.pageSize,
46
47
  };
47
48
  }
48
- 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) {
49
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
+ }
50
79
  const listener = (_value, key) => {
51
- if (key.name !== 'escape' && key.sequence !== '\u001B') {
80
+ if (!isEscapeKey(key)) {
52
81
  return;
53
82
  }
54
83
  recordPromptCancelKey(key);
@@ -59,9 +88,9 @@ function installEscapeAbortController(controller) {
59
88
  process.stdin.on('keypress', listener);
60
89
  return () => process.stdin.off('keypress', listener);
61
90
  }
62
- async function withPromptCancelGuard(callback) {
91
+ async function withPromptCancelGuard(callback, options = {}) {
63
92
  const controller = new AbortController();
64
- const removeEscapeListener = installEscapeAbortController(controller);
93
+ const removeEscapeListener = installEscapeAbortController(controller, options);
65
94
  try {
66
95
  return await callback({ signal: controller.signal });
67
96
  }
@@ -90,9 +119,18 @@ function asInquirerSelectConfig(options) {
90
119
  pageSize: options.pageSize,
91
120
  loop: options.loop,
92
121
  hideMessage: options.hideMessage,
122
+ disabledError: options.disabledError,
123
+ navigationHelp: options.navigationHelp,
124
+ navigationWarning: options.navigationWarning,
125
+ escapeBehavior: options.escapeBehavior,
93
126
  };
94
127
  }
95
- function hiddenSelectTheme() {
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) {
133
+ const keysHelpTip = navigationHelp === 'back' ? '↑↓ navigate • ⏎ select • Esc to go back' : '↑↓ navigate • ⏎ select • Ctrl+C exit';
96
134
  return {
97
135
  prefix: '',
98
136
  icon: {
@@ -101,19 +139,31 @@ function hiddenSelectTheme() {
101
139
  style: {
102
140
  message: () => '',
103
141
  highlight: (text) => text,
104
- keysHelpTip: () => '↑↓ navigate select Ctrl+C exit',
142
+ disabled: (text) => styleText('dim', text.replace(/ \(disabled\)$/u, ''), { validateStream: false }),
143
+ keysHelpTip: () => {
144
+ const warning = renderedNavigationWarning(navigationWarning);
145
+ return warning ? `${warning}\n${keysHelpTip}` : keysHelpTip;
146
+ },
105
147
  },
148
+ i18n: disabledError ? { disabledError } : undefined,
106
149
  };
107
150
  }
108
151
  function withHiddenSelectMessage(config) {
109
- if (!config.hideMessage) {
152
+ if (!config.hideMessage &&
153
+ !config.disabledError &&
154
+ !config.navigationHelp &&
155
+ !config.navigationWarning &&
156
+ !config.escapeBehavior) {
110
157
  return config;
111
158
  }
112
- const { hideMessage: _hideMessage, ...inquirerConfig } = config;
159
+ const { disabledError, hideMessage: _hideMessage, navigationHelp, navigationWarning, escapeBehavior: _escapeBehavior, ...inquirerConfig } = config;
160
+ if (!config.hideMessage) {
161
+ return inquirerConfig;
162
+ }
113
163
  return {
114
164
  ...inquirerConfig,
115
165
  message: '',
116
- theme: hiddenSelectTheme(),
166
+ theme: hiddenSelectTheme(disabledError, navigationHelp, navigationWarning),
117
167
  };
118
168
  }
119
169
  function asInquirerConfirmConfig(options) {
@@ -139,10 +189,13 @@ export function isPromptCancel(value) {
139
189
  return value === promptCancelled;
140
190
  }
141
191
  export async function selectPrompt(options) {
192
+ const guardOptions = {
193
+ escapeBehavior: options.escapeBehavior,
194
+ };
142
195
  if (isClackSelectOptions(options)) {
143
- return withPromptCancelGuard((context) => inquirerSelect(withHiddenSelectMessage(asInquirerSelectConfig(options)), context));
196
+ return withPromptCancelGuard((context) => inquirerSelect(withHiddenSelectMessage(asInquirerSelectConfig(options)), context), guardOptions);
144
197
  }
145
- return withPromptCancelGuard((context) => inquirerSelect(withHiddenSelectMessage(options), context));
198
+ return withPromptCancelGuard((context) => inquirerSelect(withHiddenSelectMessage(options), context), guardOptions);
146
199
  }
147
200
  export async function inputPrompt(options) {
148
201
  return withPromptCancelGuard((context) => inquirerInput(asInquirerInputConfig(options), context));
@@ -0,0 +1,48 @@
1
+ import { execFile } from 'node:child_process';
2
+ function run(command, args, options) {
3
+ return new Promise((resolve, reject) => {
4
+ execFile(command, args, { cwd: options.cwd }, (error, stdout) => {
5
+ if (error) {
6
+ reject(error);
7
+ return;
8
+ }
9
+ resolve({ stdout });
10
+ });
11
+ });
12
+ }
13
+ export function renderServiceRuntimeStatusLine(status) {
14
+ if (status.kind === 'running')
15
+ return 'Status: ● Services running';
16
+ if (status.kind === 'docker-not-running')
17
+ return 'Status: ● Docker not running';
18
+ return 'Status: ● Services stopped';
19
+ }
20
+ export async function getServiceRuntimeStatus(target, environmentStatus, runner = run) {
21
+ try {
22
+ await runner('docker', ['info', '--format', '{{.ServerVersion}}'], { cwd: target });
23
+ }
24
+ catch {
25
+ return { kind: 'docker-not-running' };
26
+ }
27
+ if (environmentStatus.kind !== 'environment' ||
28
+ environmentStatus.composeFiles.length === 0 ||
29
+ environmentStatus.composeErrors.length > 0) {
30
+ return { kind: 'stopped' };
31
+ }
32
+ const args = [
33
+ 'compose',
34
+ ...environmentStatus.composeFiles.flatMap((file) => ['-f', file]),
35
+ 'ps',
36
+ '--services',
37
+ '--filter',
38
+ 'status=running',
39
+ ];
40
+ let result;
41
+ try {
42
+ result = await runner('docker', args, { cwd: target });
43
+ }
44
+ catch {
45
+ return { kind: 'stopped' };
46
+ }
47
+ return result.stdout.trim() ? { kind: 'running' } : { kind: 'stopped' };
48
+ }