@wpmoo/toolkit 0.9.4 → 0.9.5

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/cli.js CHANGED
@@ -22,6 +22,7 @@ import { promptRepositoryUrl } from './prompt-repositories.js';
22
22
  import { inferGitHubOwner, inferRepoPath, normalizeRepositoryUrl } from './repo-url.js';
23
23
  import { addModuleRepo, listModuleRepos, removeModuleRepo } from './repo-actions.js';
24
24
  import { renderSafeResetPreview, safeResetEnvironment } from './safe-reset.js';
25
+ import { getServiceRuntimeStatus, renderServiceRuntimeStatusLine, } from './service-runtime-status.js';
25
26
  import { listSources, renderSourceList, sourceListJson, sourceSyncJson, syncSources, } from './source-actions.js';
26
27
  import { backupTargetPath, expectedTargetConfirmation, inspectEnvironmentTarget, renderExistingEnvironmentSummary, renderForeignEnvironmentTargetWarning, } from './environment-target-preflight.js';
27
28
  import { getGitHubPrerequisiteStatus, renderGitHubPrerequisiteGuidance, } from './github-prerequisites.js';
@@ -195,12 +196,16 @@ function renderStartupBanner(details, latestVersion) {
195
196
  const versionLine = startupVersionLine(latestVersion);
196
197
  return renderBanner(details?.(versionLine), details ? { version: versionLine } : undefined);
197
198
  }
198
- function renderCockpitStatusLines(status, lastStatus) {
199
- return [renderStartupEnvironmentLine(status), lastStatus];
199
+ function renderCockpitStatusLines(status, serviceStatus, lastStatus) {
200
+ return [renderStartupEnvironmentLine(status), renderServiceRuntimeStatusLine(serviceStatus), lastStatus];
200
201
  }
201
202
  function renderLastCommandStatus(command) {
202
203
  return `Last: ${command.label} ✓ completed`;
203
204
  }
205
+ function renderLastCommandError(command, error) {
206
+ const message = error instanceof Error ? error.message : String(error);
207
+ return `Last: ${command.label} ✗ Error: ${message}`;
208
+ }
204
209
  function clearCockpitScreen() {
205
210
  if (process.stdout.isTTY) {
206
211
  process.stdout.write('\u001B[2J\u001B[H');
@@ -245,8 +250,8 @@ async function showStartup(argv, skipUpdateCheck, details) {
245
250
  }
246
251
  console.log();
247
252
  }
248
- async function selectCockpitCommandFromMenu() {
249
- const selection = await selectCockpitTopLevelMenu();
253
+ async function selectCockpitCommandFromMenu(serviceStatus) {
254
+ const selection = await selectCockpitTopLevelMenu({ serviceStatus });
250
255
  if (selection.kind === 'exit') {
251
256
  return 'exit';
252
257
  }
@@ -1050,22 +1055,37 @@ export async function runCli(cliArgv = process.argv.slice(2), cwd = process.cwd(
1050
1055
  return;
1051
1056
  }
1052
1057
  let lastStatus = 'Last: Ready';
1053
- const initialStatus = await getEnvironmentStatus(cwd);
1054
- await showStartup(argv, skipUpdateCheck, () => renderCockpitStatusLines(initialStatus, lastStatus));
1058
+ let status = await getEnvironmentStatus(cwd);
1059
+ let serviceStatus = await getServiceRuntimeStatus(cwd, status);
1060
+ await showStartup(argv, skipUpdateCheck, () => renderCockpitStatusLines(status, serviceStatus, lastStatus));
1055
1061
  while (true) {
1056
1062
  try {
1057
- const command = await selectCockpitCommandFromMenu();
1063
+ const command = await selectCockpitCommandFromMenu(serviceStatus);
1058
1064
  if (command === 'exit') {
1059
1065
  return;
1060
1066
  }
1061
- const outcome = await runCockpitCommand(command, cwd);
1067
+ let outcome = 'continue';
1068
+ let commandFailed = false;
1069
+ try {
1070
+ outcome = await runCockpitCommand(command, cwd);
1071
+ }
1072
+ catch (error) {
1073
+ if (isMenuBackSignal(error)) {
1074
+ continue;
1075
+ }
1076
+ commandFailed = true;
1077
+ lastStatus = renderLastCommandError(command, error);
1078
+ }
1062
1079
  if (outcome === 'exit') {
1063
1080
  return;
1064
1081
  }
1065
- lastStatus = renderLastCommandStatus(command);
1066
- const status = await getEnvironmentStatus(cwd);
1082
+ if (!commandFailed) {
1083
+ lastStatus = renderLastCommandStatus(command);
1084
+ }
1085
+ status = await getEnvironmentStatus(cwd);
1086
+ serviceStatus = await getServiceRuntimeStatus(cwd, status);
1067
1087
  clearCockpitScreen();
1068
- console.log(renderBanner(renderCockpitStatusLines(status, lastStatus), { version: startupVersionLine() }));
1088
+ console.log(renderBanner(renderCockpitStatusLines(status, serviceStatus, lastStatus), { version: startupVersionLine() }));
1069
1089
  console.log();
1070
1090
  }
1071
1091
  catch (error) {
@@ -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,15 +101,29 @@ 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);
80
119
  const selected = await deps.select({
81
120
  message: '',
82
- choices: [...topLevelChoices],
83
- default: topLevelCommands[0],
84
- pageSize: topLevelPageSize(topLevelChoices.length),
121
+ choices: [...choices],
122
+ default: defaultCommand(options.serviceStatus),
123
+ pageSize: topLevelPageSize(choices.length),
85
124
  loop: false,
86
125
  hideMessage: true,
126
+ disabledError: disabledError(options.serviceStatus),
87
127
  });
88
128
  deps.handleCancel(selected, 'exit');
89
129
  if (selected === 'exit') {
@@ -1,4 +1,5 @@
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';
@@ -90,9 +91,10 @@ function asInquirerSelectConfig(options) {
90
91
  pageSize: options.pageSize,
91
92
  loop: options.loop,
92
93
  hideMessage: options.hideMessage,
94
+ disabledError: options.disabledError,
93
95
  };
94
96
  }
95
- function hiddenSelectTheme() {
97
+ function hiddenSelectTheme(disabledError) {
96
98
  return {
97
99
  prefix: '',
98
100
  icon: {
@@ -101,19 +103,21 @@ function hiddenSelectTheme() {
101
103
  style: {
102
104
  message: () => '',
103
105
  highlight: (text) => text,
106
+ disabled: (text) => styleText('dim', text.replace(/ \(disabled\)$/u, ''), { validateStream: false }),
104
107
  keysHelpTip: () => '↑↓ navigate • ⏎ select • Ctrl+C exit',
105
108
  },
109
+ i18n: disabledError ? { disabledError } : undefined,
106
110
  };
107
111
  }
108
112
  function withHiddenSelectMessage(config) {
109
113
  if (!config.hideMessage) {
110
114
  return config;
111
115
  }
112
- const { hideMessage: _hideMessage, ...inquirerConfig } = config;
116
+ const { disabledError, hideMessage: _hideMessage, ...inquirerConfig } = config;
113
117
  return {
114
118
  ...inquirerConfig,
115
119
  message: '',
116
- theme: hiddenSelectTheme(),
120
+ theme: hiddenSelectTheme(disabledError),
117
121
  };
118
122
  }
119
123
  function asInquirerConfirmConfig(options) {
@@ -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
+ }
package/dist/templates.js CHANGED
@@ -279,6 +279,10 @@ const ANSI_DIM = '\u001B[2m';
279
279
  const ANSI_INFO = '\u001B[38;2;139;166;190m';
280
280
  const ANSI_TAGLINE = '\u001B[38;2;120;157;181m';
281
281
  const ANSI_META = '\u001B[38;2;218;236;246m';
282
+ const ANSI_SUCCESS = '\u001B[32m';
283
+ const ANSI_ERROR = '\u001B[31m';
284
+ const ANSI_WARNING = '\u001B[33m';
285
+ const ANSI_DEFAULT_FOREGROUND = '\u001B[39m';
282
286
  const ANSI_RESET = '\u001B[0m';
283
287
  const BANNER_TAGLINE = 'Development, staging and production workflows for Odoo projects.';
284
288
  function gradientColor(column, width) {
@@ -307,15 +311,50 @@ function renderDimInfo(value) {
307
311
  function renderMetaInfo(value) {
308
312
  return `${ANSI_META}${value}${ANSI_RESET}`;
309
313
  }
314
+ function renderSuccessInfo(value) {
315
+ return `${ANSI_SUCCESS}${value}${ANSI_DEFAULT_FOREGROUND}`;
316
+ }
317
+ function renderErrorInfo(value) {
318
+ return `${ANSI_ERROR}${value}${ANSI_DEFAULT_FOREGROUND}`;
319
+ }
320
+ function renderWarningInfo(value) {
321
+ return `${ANSI_WARNING}${value}${ANSI_DEFAULT_FOREGROUND}`;
322
+ }
310
323
  function renderTaglineInfo(value) {
311
324
  return `${ANSI_TAGLINE}${value}${ANSI_RESET}`;
312
325
  }
313
326
  function renderBannerDetail(value) {
314
- const match = /^(Environment|Last):(.*)$/u.exec(value);
327
+ const match = /^(Environment|Status|Last):(.*)$/u.exec(value);
315
328
  if (!match) {
316
329
  return renderDimInfo(value);
317
330
  }
318
- return `${renderMetaInfo(`${match[1]}:`)}${renderDimInfo(match[2] ?? '')}`;
331
+ const label = match[1];
332
+ const detail = match[2] ?? '';
333
+ if (label === 'Status') {
334
+ const statusMatch = /^ (●) (.*)$/u.exec(detail);
335
+ if (statusMatch) {
336
+ const marker = statusMatch[1] ?? '';
337
+ const message = statusMatch[2] ?? '';
338
+ const renderMarker = message === 'Services running' ? renderSuccessInfo : renderWarningInfo;
339
+ return `${renderMetaInfo(`${label}:`)} ${renderMarker(marker)}${renderTaglineInfo(` ${message}`)}`;
340
+ }
341
+ }
342
+ if (label === 'Last') {
343
+ const completedMatch = /^(.*?)( ✓ completed)$/u.exec(detail);
344
+ if (completedMatch) {
345
+ return `${renderMetaInfo(`${label}:`)}${renderDimInfo(completedMatch[1] ?? '')}${renderSuccessInfo(completedMatch[2] ?? '')}`;
346
+ }
347
+ const errorMatch = /^(.*?)( ✗ Error)(: .*)?$/u.exec(detail);
348
+ if (errorMatch) {
349
+ return [
350
+ renderMetaInfo(`${label}:`),
351
+ renderDimInfo(errorMatch[1] ?? ''),
352
+ renderErrorInfo(errorMatch[2] ?? ''),
353
+ renderTaglineInfo(errorMatch[3] ?? ''),
354
+ ].join('');
355
+ }
356
+ }
357
+ return `${renderMetaInfo(`${label}:`)}${renderDimInfo(detail)}`;
319
358
  }
320
359
  export function renderBanner(details = [], options = {}) {
321
360
  const title = `${applyBannerGradient('WPMoo Toolkit')}${options.version ? ` ${renderDimInfo(options.version)}` : ''}`;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@wpmoo/toolkit",
3
- "version": "0.9.4",
3
+ "version": "0.9.5",
4
4
  "description": "WPMoo Toolkit for development, staging, and production lifecycle workflows.",
5
5
  "type": "module",
6
6
  "repository": {