@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.
- package/dist/cli.js +393 -41
- package/dist/cockpit/command-registry.js +4 -0
- package/dist/cockpit/daily-prompts.js +29 -11
- package/dist/cockpit/menu.js +56 -13
- package/dist/cockpit/module-action-menu.js +40 -0
- package/dist/cockpit/module-browser.js +117 -0
- package/dist/daily-actions.js +40 -3
- package/dist/databases.js +46 -0
- package/dist/help.js +2 -2
- package/dist/menu-navigation.js +2 -2
- package/dist/module-actions.js +50 -1
- package/dist/prompts/index.js +65 -12
- package/dist/service-runtime-status.js +48 -0
- package/dist/templates.js +44 -5
- package/package.json +1 -1
package/dist/cockpit/menu.js
CHANGED
|
@@ -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
|
|
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
|
-
|
|
42
|
-
|
|
43
|
-
|
|
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: [...
|
|
83
|
-
default:
|
|
84
|
-
pageSize: topLevelPageSize(
|
|
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,
|
|
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
|
+
}
|
package/dist/daily-actions.js
CHANGED
|
@@ -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
|
package/dist/menu-navigation.js
CHANGED
|
@@ -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
|
|
13
|
+
return title;
|
|
14
14
|
}
|
|
15
15
|
export function menuPromptMessage(message, action) {
|
|
16
|
-
return
|
|
16
|
+
return message;
|
|
17
17
|
}
|
|
18
18
|
export function promptCancelOutcome(cancelled, action, key) {
|
|
19
19
|
if (!cancelled) {
|
package/dist/module-actions.js
CHANGED
|
@@ -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);
|
package/dist/prompts/index.js
CHANGED
|
@@ -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
|
|
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
|
|
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
|
|
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
|
-
|
|
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
|
+
}
|