@wpmoo/toolkit 0.9.3 → 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
@@ -14,6 +14,7 @@ import { isDailyActionCommand, runDailyAction } from './daily-actions.js';
14
14
  import { getDoctorReport, runDoctor } from './doctor.js';
15
15
  import { getOriginUrl, realGit } from './git.js';
16
16
  import { renderHelp } from './help.js';
17
+ import { runLocalCockpit } from './local-cockpit.js';
17
18
  import { addModuleToSourceRepo, listModulesInSourceRepo, removeModuleFromSourceRepo, } from './module-actions.js';
18
19
  import { supportedOdooVersions } from './odoo-versions.js';
19
20
  import { renderRepositorySetupNote } from './prompt-copy.js';
@@ -21,6 +22,7 @@ import { promptRepositoryUrl } from './prompt-repositories.js';
21
22
  import { inferGitHubOwner, inferRepoPath, normalizeRepositoryUrl } from './repo-url.js';
22
23
  import { addModuleRepo, listModuleRepos, removeModuleRepo } from './repo-actions.js';
23
24
  import { renderSafeResetPreview, safeResetEnvironment } from './safe-reset.js';
25
+ import { getServiceRuntimeStatus, renderServiceRuntimeStatusLine, } from './service-runtime-status.js';
24
26
  import { listSources, renderSourceList, sourceListJson, sourceSyncJson, syncSources, } from './source-actions.js';
25
27
  import { backupTargetPath, expectedTargetConfirmation, inspectEnvironmentTarget, renderExistingEnvironmentSummary, renderForeignEnvironmentTargetWarning, } from './environment-target-preflight.js';
26
28
  import { getGitHubPrerequisiteStatus, renderGitHubPrerequisiteGuidance, } from './github-prerequisites.js';
@@ -114,10 +116,25 @@ function jsonOption(values) {
114
116
  function printJson(value) {
115
117
  console.log(JSON.stringify(value));
116
118
  }
117
- function yellow(value) {
118
- if (!process.stdout.isTTY || process.env.NO_COLOR !== undefined)
119
+ function supportsAnsi() {
120
+ return Boolean(process.stdout.isTTY) && process.env.NO_COLOR === undefined;
121
+ }
122
+ function ansi(value, open, close) {
123
+ if (!supportsAnsi())
119
124
  return value;
120
- return `\u001b[33m${value}\u001b[39m`;
125
+ return `${open}${value}${close}`;
126
+ }
127
+ function yellow(value) {
128
+ return ansi(value, '\u001B[33m', '\u001B[39m');
129
+ }
130
+ function cyan(value) {
131
+ return ansi(value, '\u001B[36m', '\u001B[39m');
132
+ }
133
+ function boldGreen(value) {
134
+ return ansi(value, '\u001B[1m\u001B[32m', '\u001B[39m\u001B[22m');
135
+ }
136
+ function dim(value) {
137
+ return ansi(value, '\u001B[2m', '\u001B[22m');
121
138
  }
122
139
  function shellQuote(value) {
123
140
  if (/^[A-Za-z0-9_./:-]+$/.test(value))
@@ -132,12 +149,22 @@ function renderedSourceRepoPath(target, sourceType, repoPath) {
132
149
  }
133
150
  function renderPostCreateGuidance(target, cwd) {
134
151
  const relativeTarget = relative(cwd, target) || '.';
135
- return yellow([
136
- 'Environment is ready. Enter the development folder, then run the local WPMoo cockpit:',
152
+ const cdCommand = `cd ${shellQuote(relativeTarget)}`;
153
+ if (!supportsAnsi()) {
154
+ return [
155
+ 'Environment is ready. Open it now, or copy these commands:',
156
+ '',
157
+ cdCommand,
158
+ './moo',
159
+ ].join('\n');
160
+ }
161
+ return [
162
+ boldGreen('✓ Environment is ready.'),
163
+ cyan('Open it now, or copy these commands:'),
137
164
  '',
138
- `cd ${shellQuote(relativeTarget)}`,
139
- './moo',
140
- ].join('\n'));
165
+ yellow(cdCommand),
166
+ yellow('./moo'),
167
+ ].join('\n');
141
168
  }
142
169
  function validateRepoName(value) {
143
170
  const normalized = value.trim();
@@ -169,12 +196,16 @@ function renderStartupBanner(details, latestVersion) {
169
196
  const versionLine = startupVersionLine(latestVersion);
170
197
  return renderBanner(details?.(versionLine), details ? { version: versionLine } : undefined);
171
198
  }
172
- function renderCockpitStatusLines(status, lastStatus) {
173
- return [renderStartupEnvironmentLine(status), lastStatus];
199
+ function renderCockpitStatusLines(status, serviceStatus, lastStatus) {
200
+ return [renderStartupEnvironmentLine(status), renderServiceRuntimeStatusLine(serviceStatus), lastStatus];
174
201
  }
175
202
  function renderLastCommandStatus(command) {
176
203
  return `Last: ${command.label} ✓ completed`;
177
204
  }
205
+ function renderLastCommandError(command, error) {
206
+ const message = error instanceof Error ? error.message : String(error);
207
+ return `Last: ${command.label} ✗ Error: ${message}`;
208
+ }
178
209
  function clearCockpitScreen() {
179
210
  if (process.stdout.isTTY) {
180
211
  process.stdout.write('\u001B[2J\u001B[H');
@@ -219,8 +250,8 @@ async function showStartup(argv, skipUpdateCheck, details) {
219
250
  }
220
251
  console.log();
221
252
  }
222
- async function selectCockpitCommandFromMenu() {
223
- const selection = await selectCockpitTopLevelMenu();
253
+ async function selectCockpitCommandFromMenu(serviceStatus) {
254
+ const selection = await selectCockpitTopLevelMenu({ serviceStatus });
224
255
  if (selection.kind === 'exit') {
225
256
  return 'exit';
226
257
  }
@@ -260,7 +291,7 @@ async function resolveEnvironmentTargetFromPrompts(product, cancelAction) {
260
291
  message: 'This environment folder already exists. What do you want to do?',
261
292
  options: [
262
293
  { value: 'update-existing', label: 'Update existing environment' },
263
- { value: 'reinstall-environment', label: 'Reinstall environment from backup' },
294
+ { value: 'reinstall-environment', label: 'Back up existing environment folder and create a new one' },
264
295
  { value: 'delete-environment', label: 'Delete environment' },
265
296
  { value: 'cancel', label: 'Cancel' },
266
297
  ],
@@ -837,11 +868,11 @@ async function ensureGitHubRepositories(options, interactive) {
837
868
  throw new Error(`Dev environment repository is non-empty or could not be verified: ${blocked.map((repo) => repo.slug).join(', ')}`);
838
869
  }
839
870
  if (interactive && accessible.length > 0) {
840
- notePrompt([
871
+ notePrompt(dim([
841
872
  'These GitHub repositories already exist and are accessible:',
842
873
  '',
843
874
  ...accessible.map((repository) => `- ${repository.label}: ${repository.slug}`),
844
- ].join('\n'), 'Repository check');
875
+ ].join('\n')), 'Repository check');
845
876
  }
846
877
  if (missing.length === 0) {
847
878
  return;
@@ -922,7 +953,19 @@ async function finishCreateFlow(result, cwd, interactive) {
922
953
  console.log(`- ${command}`);
923
954
  return;
924
955
  }
925
- notePrompt(renderPostCreateGuidance(options.target, cwd), 'Next steps');
956
+ notePrompt(renderPostCreateGuidance(options.target, cwd), 'Next steps', { indent: false });
957
+ if (interactive) {
958
+ const shouldOpenCockpit = await confirmPrompt({
959
+ message: 'Open the local WPMoo cockpit now?',
960
+ active: 'Y',
961
+ inactive: 'n',
962
+ initialValue: true,
963
+ });
964
+ if (shouldOpenCockpit === true) {
965
+ await runLocalCockpit(options.target);
966
+ return;
967
+ }
968
+ }
926
969
  outroPrompt(`Created Odoo dev overlay in ${options.target}. Review staged changes, then commit.`);
927
970
  }
928
971
  async function runCockpitCommand(command, cwd) {
@@ -1012,22 +1055,37 @@ export async function runCli(cliArgv = process.argv.slice(2), cwd = process.cwd(
1012
1055
  return;
1013
1056
  }
1014
1057
  let lastStatus = 'Last: Ready';
1015
- const initialStatus = await getEnvironmentStatus(cwd);
1016
- 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));
1017
1061
  while (true) {
1018
1062
  try {
1019
- const command = await selectCockpitCommandFromMenu();
1063
+ const command = await selectCockpitCommandFromMenu(serviceStatus);
1020
1064
  if (command === 'exit') {
1021
1065
  return;
1022
1066
  }
1023
- 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
+ }
1024
1079
  if (outcome === 'exit') {
1025
1080
  return;
1026
1081
  }
1027
- lastStatus = renderLastCommandStatus(command);
1028
- const status = await getEnvironmentStatus(cwd);
1082
+ if (!commandFailed) {
1083
+ lastStatus = renderLastCommandStatus(command);
1084
+ }
1085
+ status = await getEnvironmentStatus(cwd);
1086
+ serviceStatus = await getServiceRuntimeStatus(cwd, status);
1029
1087
  clearCockpitScreen();
1030
- console.log(renderBanner(renderCockpitStatusLines(status, lastStatus), { version: startupVersionLine() }));
1088
+ console.log(renderBanner(renderCockpitStatusLines(status, serviceStatus, lastStatus), { version: startupVersionLine() }));
1031
1089
  console.log();
1032
1090
  }
1033
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') {
@@ -0,0 +1,15 @@
1
+ import { spawn } from 'node:child_process';
2
+ import { join } from 'node:path';
3
+ export async function runLocalCockpit(target) {
4
+ const child = spawn(join(target, 'moo'), [], {
5
+ cwd: target,
6
+ stdio: 'inherit',
7
+ });
8
+ const exitCode = await new Promise((resolve, reject) => {
9
+ child.on('error', reject);
10
+ child.on('close', resolve);
11
+ });
12
+ if (exitCode !== 0) {
13
+ throw new Error(`Local WPMoo cockpit exited with code ${exitCode ?? 'unknown'}: ${join(target, 'moo')}`);
14
+ }
15
+ }
@@ -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) {
@@ -162,11 +166,12 @@ export function introPrompt(title) {
162
166
  console.log(title);
163
167
  console.log(rule);
164
168
  }
165
- export function notePrompt(message, title = 'Note') {
169
+ export function notePrompt(message, title = 'Note', options = {}) {
166
170
  const lines = message.split('\n');
171
+ const prefix = options.indent === false ? '' : ' ';
167
172
  console.log(`[${title}]`);
168
173
  for (const line of lines) {
169
- console.log(` ${line}`);
174
+ console.log(`${prefix}${line}`);
170
175
  }
171
176
  }
172
177
  export function outroPrompt(message) {
@@ -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.3",
3
+ "version": "0.9.5",
4
4
  "description": "WPMoo Toolkit for development, staging, and production lifecycle workflows.",
5
5
  "type": "module",
6
6
  "repository": {