@wpmoo/toolkit 0.9.23 → 0.9.25
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 +54 -3
- package/dist/cockpit/daily-prompts.js +15 -1
- package/dist/daily-actions.js +51 -10
- package/dist/databases.js +24 -0
- package/dist/help.js +2 -2
- package/dist/module-manifest.js +298 -0
- package/dist/module-quality.js +213 -19
- package/dist/module-target-resolver.js +91 -0
- package/dist/safe-reset.js +244 -17
- package/dist/scaffold.js +2 -1
- package/dist/service-runtime-status.js +65 -3
- package/dist/templates.js +110 -8
- package/package.json +1 -1
package/dist/cli.js
CHANGED
|
@@ -20,7 +20,8 @@ import { getDoctorReport, runDoctor } from './doctor.js';
|
|
|
20
20
|
import { getOriginUrl, realGit } from './git.js';
|
|
21
21
|
import { renderHelp } from './help.js';
|
|
22
22
|
import { runLocalCockpit } from './local-cockpit.js';
|
|
23
|
-
import { addModuleToSourceRepo, removeModuleFromSourceRepo, } from './module-actions.js';
|
|
23
|
+
import { addModuleToSourceRepo, listModulesInEnvironment, removeModuleFromSourceRepo, } from './module-actions.js';
|
|
24
|
+
import { resolveModuleTarget } from './module-target-resolver.js';
|
|
24
25
|
import { supportedOdooVersions } from './odoo-versions.js';
|
|
25
26
|
import { renderRepositorySetupNote } from './prompt-copy.js';
|
|
26
27
|
import { promptRepositoryUrl } from './prompt-repositories.js';
|
|
@@ -270,7 +271,17 @@ async function showStartup(argv, skipUpdateCheck, details) {
|
|
|
270
271
|
console.log();
|
|
271
272
|
}
|
|
272
273
|
async function selectCockpitCommandFromMenu(serviceStatus, moduleCount, sourceRepoCount) {
|
|
273
|
-
const
|
|
274
|
+
const legacyServiceStatus = serviceStatus.kind === 'services-running' ||
|
|
275
|
+
serviceStatus.kind === 'db-ready' ||
|
|
276
|
+
serviceStatus.kind === 'odoo-not-ready' ||
|
|
277
|
+
serviceStatus.kind === 'fully-ready'
|
|
278
|
+
? { kind: 'running' }
|
|
279
|
+
: serviceStatus;
|
|
280
|
+
const selection = await selectCockpitTopLevelMenu({
|
|
281
|
+
serviceStatus: legacyServiceStatus,
|
|
282
|
+
moduleCount,
|
|
283
|
+
sourceRepoCount,
|
|
284
|
+
});
|
|
274
285
|
if (selection.kind === 'exit') {
|
|
275
286
|
return 'exit';
|
|
276
287
|
}
|
|
@@ -1109,6 +1120,46 @@ function dailyActionSelectedLabel(command, argv) {
|
|
|
1109
1120
|
}
|
|
1110
1121
|
return undefined;
|
|
1111
1122
|
}
|
|
1123
|
+
function dailyActionModuleArgIndex(command) {
|
|
1124
|
+
return ['install', 'update', 'test', 'pot'].includes(command) ? 0 : undefined;
|
|
1125
|
+
}
|
|
1126
|
+
function moduleTargetLabel(module) {
|
|
1127
|
+
return `${module.moduleName} (${module.sourceType}/${module.repoPath})`;
|
|
1128
|
+
}
|
|
1129
|
+
function moduleTargetResolutionError(resolution) {
|
|
1130
|
+
const candidates = resolution.candidates.map(moduleTargetLabel).join(', ');
|
|
1131
|
+
if (resolution.kind === 'ambiguous') {
|
|
1132
|
+
return new Error(`Ambiguous module target "${resolution.query}": ${candidates}.`);
|
|
1133
|
+
}
|
|
1134
|
+
return new Error(candidates
|
|
1135
|
+
? `No module matches "${resolution.query}". Did you mean: ${candidates}?`
|
|
1136
|
+
: `No module matches "${resolution.query}".`);
|
|
1137
|
+
}
|
|
1138
|
+
async function resolveDailyActionModuleTargets(command, argv, cwd) {
|
|
1139
|
+
const moduleArgIndex = dailyActionModuleArgIndex(command);
|
|
1140
|
+
if (moduleArgIndex === undefined) {
|
|
1141
|
+
return [...argv];
|
|
1142
|
+
}
|
|
1143
|
+
const moduleArg = argv[moduleArgIndex];
|
|
1144
|
+
if (!moduleArg || moduleArg.startsWith('-')) {
|
|
1145
|
+
return [...argv];
|
|
1146
|
+
}
|
|
1147
|
+
const modules = await listModulesInEnvironment(cwd);
|
|
1148
|
+
if (modules.length === 0) {
|
|
1149
|
+
return [...argv];
|
|
1150
|
+
}
|
|
1151
|
+
const resolvedModuleNames = moduleArg.split(',').map((query) => {
|
|
1152
|
+
const trimmedQuery = query.trim();
|
|
1153
|
+
const resolution = resolveModuleTarget(trimmedQuery, modules);
|
|
1154
|
+
if (resolution.kind !== 'exact') {
|
|
1155
|
+
throw moduleTargetResolutionError(resolution);
|
|
1156
|
+
}
|
|
1157
|
+
return resolution.module.moduleName;
|
|
1158
|
+
});
|
|
1159
|
+
const resolvedArgv = [...argv];
|
|
1160
|
+
resolvedArgv[moduleArgIndex] = resolvedModuleNames.join(',');
|
|
1161
|
+
return resolvedArgv;
|
|
1162
|
+
}
|
|
1112
1163
|
async function selectDatabaseArg(cwd, message, fallback, options = {}) {
|
|
1113
1164
|
const databaseResult = normalizeDatabaseListResult(await listEnvironmentDatabases(cwd, options));
|
|
1114
1165
|
const databases = databaseResult.databases;
|
|
@@ -1585,7 +1636,7 @@ export async function runCli(cliArgv = process.argv.slice(2), cwd = process.cwd(
|
|
|
1585
1636
|
}
|
|
1586
1637
|
if (isDailyActionCommand(route.command)) {
|
|
1587
1638
|
console.log(renderBanner());
|
|
1588
|
-
await runDailyAction(route.command, route.argv, cwd);
|
|
1639
|
+
await runDailyAction(route.command, await resolveDailyActionModuleTargets(route.command, route.argv, cwd), cwd);
|
|
1589
1640
|
return;
|
|
1590
1641
|
}
|
|
1591
1642
|
const options = optionsFromArgs(route.argv);
|
|
@@ -83,6 +83,18 @@ async function optionalTextArg(deps, message, fallback) {
|
|
|
83
83
|
placeholder: fallback,
|
|
84
84
|
}), fallback, deps);
|
|
85
85
|
}
|
|
86
|
+
async function optionalTextArgOrUndefined(deps, message, placeholder) {
|
|
87
|
+
const value = await deps.text({
|
|
88
|
+
message: menuPromptMessage(message, 'back'),
|
|
89
|
+
placeholder,
|
|
90
|
+
});
|
|
91
|
+
deps.handleCancel(value, 'back');
|
|
92
|
+
if (typeof value !== 'string') {
|
|
93
|
+
return undefined;
|
|
94
|
+
}
|
|
95
|
+
const trimmed = value.trim();
|
|
96
|
+
return trimmed.length === 0 ? undefined : trimmed;
|
|
97
|
+
}
|
|
86
98
|
async function databaseArg(cwd, deps, message, fallback, options = {}) {
|
|
87
99
|
const databaseResult = normalizeDatabaseListResult(await deps.databases(cwd, options));
|
|
88
100
|
const databases = databaseResult.databases;
|
|
@@ -139,7 +151,9 @@ export async function collectDailyActionArgs(command, cwd, promptDepsArg = {}) {
|
|
|
139
151
|
return [];
|
|
140
152
|
}
|
|
141
153
|
if (command === 'logs') {
|
|
142
|
-
|
|
154
|
+
const service = await optionalTextArg(deps, 'Service', 'odoo');
|
|
155
|
+
const tail = await optionalTextArgOrUndefined(deps, 'Tail line count (optional)', '100');
|
|
156
|
+
return tail ? [service, tail] : [service];
|
|
143
157
|
}
|
|
144
158
|
if (command === 'psql') {
|
|
145
159
|
return [await databaseArg(cwd, deps, 'Database', 'postgres', { includeMaintenance: true })];
|
package/dist/daily-actions.js
CHANGED
|
@@ -2,6 +2,7 @@ import { spawn } from 'node:child_process';
|
|
|
2
2
|
import { access } from 'node:fs/promises';
|
|
3
3
|
import { join } from 'node:path';
|
|
4
4
|
import { readEnvFile, selectedComposeEnvironment } from './compose-layout.js';
|
|
5
|
+
import { normalizeDatabaseName } from './databases.js';
|
|
5
6
|
import { markerPath } from './environment.js';
|
|
6
7
|
export const dailyActionCommands = [
|
|
7
8
|
'start',
|
|
@@ -49,7 +50,7 @@ function usage(command) {
|
|
|
49
50
|
if (command === 'stop')
|
|
50
51
|
return 'Usage: wpmoo stop';
|
|
51
52
|
if (command === 'logs')
|
|
52
|
-
return 'Usage: wpmoo logs [service]';
|
|
53
|
+
return 'Usage: wpmoo logs [service] [tail-lines]';
|
|
53
54
|
if (command === 'restart')
|
|
54
55
|
return 'Usage: wpmoo restart';
|
|
55
56
|
if (command === 'shell')
|
|
@@ -86,7 +87,7 @@ function moduleArgs(command, argv) {
|
|
|
86
87
|
const [modules, db, ...rest] = argv;
|
|
87
88
|
if (!modules || modules.startsWith('-') || rest.length > 0)
|
|
88
89
|
throw new Error(usage(command));
|
|
89
|
-
return db ? [modules, db] : [modules];
|
|
90
|
+
return db ? [modules, normalizeDatabaseName(db)] : [modules];
|
|
90
91
|
}
|
|
91
92
|
function positionalArgs(command, argv, min, max) {
|
|
92
93
|
if (argv.length < min || argv.length > max || argv.some((arg) => arg.startsWith('-'))) {
|
|
@@ -94,6 +95,32 @@ function positionalArgs(command, argv, min, max) {
|
|
|
94
95
|
}
|
|
95
96
|
return argv;
|
|
96
97
|
}
|
|
98
|
+
function logsArgs(argv) {
|
|
99
|
+
if (argv.length > 2 || argv.some((arg) => arg.startsWith('-'))) {
|
|
100
|
+
throw new Error(usage('logs'));
|
|
101
|
+
}
|
|
102
|
+
const [service = 'odoo', tail] = argv;
|
|
103
|
+
if (tail === undefined) {
|
|
104
|
+
return [service];
|
|
105
|
+
}
|
|
106
|
+
if (!/^[1-9][0-9]*$/u.test(tail)) {
|
|
107
|
+
throw new Error('Invalid logs tail count: expected a positive integer.');
|
|
108
|
+
}
|
|
109
|
+
return [service, tail];
|
|
110
|
+
}
|
|
111
|
+
function validateDatabaseArg(args, index) {
|
|
112
|
+
if (args[index] === undefined) {
|
|
113
|
+
return args;
|
|
114
|
+
}
|
|
115
|
+
const nextArgs = [...args];
|
|
116
|
+
nextArgs[index] = normalizeDatabaseName(nextArgs[index]);
|
|
117
|
+
return nextArgs;
|
|
118
|
+
}
|
|
119
|
+
function rejectLeadingHyphenDatabaseArg(args) {
|
|
120
|
+
if (args[0]?.startsWith('-')) {
|
|
121
|
+
normalizeDatabaseName(args[0]);
|
|
122
|
+
}
|
|
123
|
+
}
|
|
97
124
|
function restoreSnapshotArgs(argv) {
|
|
98
125
|
const args = [...argv];
|
|
99
126
|
const dryRun = args[0] === '--dry-run';
|
|
@@ -103,7 +130,8 @@ function restoreSnapshotArgs(argv) {
|
|
|
103
130
|
if (args.length < 1 || args.length > 2 || args.some((arg) => arg.startsWith('-'))) {
|
|
104
131
|
throw new Error(usage('restore-snapshot'));
|
|
105
132
|
}
|
|
106
|
-
|
|
133
|
+
const validatedArgs = args.length === 2 ? validateDatabaseArg(args, 1) : args;
|
|
134
|
+
return dryRun ? ['--dry-run', ...validatedArgs] : validatedArgs;
|
|
107
135
|
}
|
|
108
136
|
function testArgs(argv) {
|
|
109
137
|
const [modules, ...rest] = argv;
|
|
@@ -119,6 +147,9 @@ function testArgs(argv) {
|
|
|
119
147
|
if (option === '--mode' && value !== 'auto' && value !== 'init' && value !== 'update') {
|
|
120
148
|
throw new Error('Invalid value for --mode: expected auto, init, or update');
|
|
121
149
|
}
|
|
150
|
+
if (option === '--db') {
|
|
151
|
+
normalizeDatabaseName(value);
|
|
152
|
+
}
|
|
122
153
|
index += 1;
|
|
123
154
|
}
|
|
124
155
|
return argv;
|
|
@@ -129,26 +160,30 @@ function scriptArgs(command, argv) {
|
|
|
129
160
|
if (command === 'stop')
|
|
130
161
|
return ensureNoArgs(command, argv);
|
|
131
162
|
if (command === 'logs')
|
|
132
|
-
return
|
|
163
|
+
return logsArgs(argv);
|
|
133
164
|
if (command === 'restart')
|
|
134
165
|
return ensureNoArgs(command, argv);
|
|
135
166
|
if (command === 'shell')
|
|
136
167
|
return ensureNoArgs(command, argv);
|
|
137
168
|
if (command === 'psql')
|
|
138
|
-
return optionalSingleArg(command, argv, 'postgres');
|
|
169
|
+
return optionalSingleArg(command, argv, 'postgres').map(normalizeDatabaseName);
|
|
139
170
|
if (command === 'install' || command === 'update')
|
|
140
171
|
return moduleArgs(command, argv);
|
|
141
172
|
if (command === 'test')
|
|
142
173
|
return testArgs(argv);
|
|
143
|
-
if (command === 'resetdb')
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
174
|
+
if (command === 'resetdb') {
|
|
175
|
+
rejectLeadingHyphenDatabaseArg(argv);
|
|
176
|
+
return validateDatabaseArg(positionalArgs(command, argv, 0, 2), 0);
|
|
177
|
+
}
|
|
178
|
+
if (command === 'snapshot') {
|
|
179
|
+
rejectLeadingHyphenDatabaseArg(argv);
|
|
180
|
+
return validateDatabaseArg(positionalArgs(command, argv, 0, 2), 0);
|
|
181
|
+
}
|
|
147
182
|
if (command === 'restore-snapshot')
|
|
148
183
|
return restoreSnapshotArgs(argv);
|
|
149
184
|
if (command === 'lint')
|
|
150
185
|
return ensureNoArgs(command, argv);
|
|
151
|
-
return positionalArgs(command, argv, 1, 3);
|
|
186
|
+
return validateDatabaseArg(positionalArgs(command, argv, 1, 3), 1);
|
|
152
187
|
}
|
|
153
188
|
function isDestructiveCommand(command, args) {
|
|
154
189
|
if (command === 'resetdb')
|
|
@@ -263,6 +298,12 @@ function renderDailyActionOutputLine(line) {
|
|
|
263
298
|
if (line === "Running as user 'root' is a security risk.") {
|
|
264
299
|
return `${ANSI_DIM_INFO}${line}${ANSI_RESET}`;
|
|
265
300
|
}
|
|
301
|
+
if (line.includes('psycopg2.OperationalError')) {
|
|
302
|
+
return [
|
|
303
|
+
line,
|
|
304
|
+
`${ANSI_DIM_INFO}NOTE: PostgreSQL connection failed. Check ./moo status, database service readiness, and credentials before retrying.${ANSI_RESET}`,
|
|
305
|
+
].join('\n');
|
|
306
|
+
}
|
|
266
307
|
return line;
|
|
267
308
|
}
|
|
268
309
|
export function renderDailyActionOutput(output) {
|
package/dist/databases.js
CHANGED
|
@@ -1,5 +1,29 @@
|
|
|
1
1
|
import { spawn } from 'node:child_process';
|
|
2
2
|
const maintenanceDatabases = new Set(['postgres']);
|
|
3
|
+
const databaseNamePattern = /^[A-Za-z0-9_.-]+$/u;
|
|
4
|
+
export function isValidDatabaseName(value) {
|
|
5
|
+
const normalized = value.trim();
|
|
6
|
+
return (normalized.length > 0 &&
|
|
7
|
+
!normalized.startsWith('-') &&
|
|
8
|
+
databaseNamePattern.test(normalized) &&
|
|
9
|
+
!/\s/u.test(value));
|
|
10
|
+
}
|
|
11
|
+
export function normalizeDatabaseName(value) {
|
|
12
|
+
const normalized = value.trim();
|
|
13
|
+
if (!normalized) {
|
|
14
|
+
throw new Error('Invalid database name: value is required.');
|
|
15
|
+
}
|
|
16
|
+
if (/\s/u.test(value)) {
|
|
17
|
+
throw new Error('Invalid database name: whitespace is not allowed.');
|
|
18
|
+
}
|
|
19
|
+
if (normalized.startsWith('-')) {
|
|
20
|
+
throw new Error('Invalid database name: leading hyphens are not allowed.');
|
|
21
|
+
}
|
|
22
|
+
if (!databaseNamePattern.test(normalized)) {
|
|
23
|
+
throw new Error('Invalid database name: use letters, digits, underscores, dots, or hyphens without shell metacharacters or path characters.');
|
|
24
|
+
}
|
|
25
|
+
return normalized;
|
|
26
|
+
}
|
|
3
27
|
const listDatabasesQuery = [
|
|
4
28
|
'SELECT datname',
|
|
5
29
|
'FROM pg_database',
|
package/dist/help.js
CHANGED
|
@@ -24,7 +24,7 @@ Usage:
|
|
|
24
24
|
npx @wpmoo/toolkit doctor --json [--postgres]
|
|
25
25
|
npx @wpmoo/toolkit start
|
|
26
26
|
npx @wpmoo/toolkit stop
|
|
27
|
-
npx @wpmoo/toolkit logs [service]
|
|
27
|
+
npx @wpmoo/toolkit logs [service] [tail-lines]
|
|
28
28
|
npx @wpmoo/toolkit restart
|
|
29
29
|
npx @wpmoo/toolkit shell
|
|
30
30
|
npx @wpmoo/toolkit psql [db]
|
|
@@ -148,7 +148,7 @@ Task recipes:
|
|
|
148
148
|
npx @wpmoo/toolkit status
|
|
149
149
|
npx @wpmoo/toolkit doctor
|
|
150
150
|
npx @wpmoo/toolkit doctor --fix
|
|
151
|
-
npx @wpmoo/toolkit logs [service]
|
|
151
|
+
npx @wpmoo/toolkit logs [service] [tail-lines]
|
|
152
152
|
npx @wpmoo/toolkit restart
|
|
153
153
|
|
|
154
154
|
Machine-readable JSON output:
|
|
@@ -0,0 +1,298 @@
|
|
|
1
|
+
function createParserState(content) {
|
|
2
|
+
return {
|
|
3
|
+
content,
|
|
4
|
+
parser: {
|
|
5
|
+
index: 0,
|
|
6
|
+
line: 1,
|
|
7
|
+
column: 1,
|
|
8
|
+
},
|
|
9
|
+
};
|
|
10
|
+
}
|
|
11
|
+
function makeError(state, message) {
|
|
12
|
+
return { message, index: state.index, line: state.line, column: state.column };
|
|
13
|
+
}
|
|
14
|
+
function throwParseError(state, message) {
|
|
15
|
+
const { line, column } = makeError(state, message);
|
|
16
|
+
throw new Error(`Parse error at ${line}:${column}: ${message}`);
|
|
17
|
+
}
|
|
18
|
+
function isWhitespace(char) {
|
|
19
|
+
return /\s/u.test(char);
|
|
20
|
+
}
|
|
21
|
+
function peek(state, content, offset = 0) {
|
|
22
|
+
return content[state.index + offset] ?? '';
|
|
23
|
+
}
|
|
24
|
+
function consumeChar(state, content) {
|
|
25
|
+
const char = peek(state, content);
|
|
26
|
+
if (char === '') {
|
|
27
|
+
return '';
|
|
28
|
+
}
|
|
29
|
+
state.index += 1;
|
|
30
|
+
if (char === '\n') {
|
|
31
|
+
state.line += 1;
|
|
32
|
+
state.column = 1;
|
|
33
|
+
}
|
|
34
|
+
else {
|
|
35
|
+
state.column += 1;
|
|
36
|
+
}
|
|
37
|
+
return char;
|
|
38
|
+
}
|
|
39
|
+
function skipWhitespaceAndComments(state, content) {
|
|
40
|
+
while (state.index < content.length) {
|
|
41
|
+
const char = peek(state, content);
|
|
42
|
+
if (isWhitespace(char)) {
|
|
43
|
+
consumeChar(state, content);
|
|
44
|
+
continue;
|
|
45
|
+
}
|
|
46
|
+
if (char === '#') {
|
|
47
|
+
while (state.index < content.length && peek(state, content) !== '\n') {
|
|
48
|
+
consumeChar(state, content);
|
|
49
|
+
}
|
|
50
|
+
continue;
|
|
51
|
+
}
|
|
52
|
+
break;
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
function parseString(state, content) {
|
|
56
|
+
const quote = consumeChar(state, content);
|
|
57
|
+
const chars = [];
|
|
58
|
+
while (state.index < content.length) {
|
|
59
|
+
const char = consumeChar(state, content);
|
|
60
|
+
if (char === '\\') {
|
|
61
|
+
const escaped = consumeChar(state, content);
|
|
62
|
+
if (!escaped) {
|
|
63
|
+
throwParseError(state, 'unterminated string escape');
|
|
64
|
+
}
|
|
65
|
+
if (escaped === 'n') {
|
|
66
|
+
chars.push('\n');
|
|
67
|
+
}
|
|
68
|
+
else if (escaped === 'r') {
|
|
69
|
+
chars.push('\r');
|
|
70
|
+
}
|
|
71
|
+
else if (escaped === 't') {
|
|
72
|
+
chars.push('\t');
|
|
73
|
+
}
|
|
74
|
+
else if (escaped === quote) {
|
|
75
|
+
chars.push(quote);
|
|
76
|
+
}
|
|
77
|
+
else if (escaped === '\\') {
|
|
78
|
+
chars.push('\\');
|
|
79
|
+
}
|
|
80
|
+
else {
|
|
81
|
+
chars.push(escaped);
|
|
82
|
+
}
|
|
83
|
+
continue;
|
|
84
|
+
}
|
|
85
|
+
if (char === quote) {
|
|
86
|
+
return chars.join('');
|
|
87
|
+
}
|
|
88
|
+
if (char === '\n' || char === '\r') {
|
|
89
|
+
throwParseError(state, 'unterminated string literal');
|
|
90
|
+
}
|
|
91
|
+
chars.push(char);
|
|
92
|
+
}
|
|
93
|
+
throwParseError(state, 'unterminated string literal');
|
|
94
|
+
}
|
|
95
|
+
function parseIdentifier(state, content) {
|
|
96
|
+
const chars = [];
|
|
97
|
+
const start = state.index;
|
|
98
|
+
const first = peek(state, content);
|
|
99
|
+
if (!/[A-Za-z_]/u.test(first)) {
|
|
100
|
+
throwParseError(state, 'expected identifier');
|
|
101
|
+
}
|
|
102
|
+
chars.push(consumeChar(state, content));
|
|
103
|
+
while (state.index < content.length) {
|
|
104
|
+
const char = peek(state, content);
|
|
105
|
+
if (/[A-Za-z0-9_]/u.test(char)) {
|
|
106
|
+
chars.push(consumeChar(state, content));
|
|
107
|
+
continue;
|
|
108
|
+
}
|
|
109
|
+
break;
|
|
110
|
+
}
|
|
111
|
+
if (chars.length === 0) {
|
|
112
|
+
throwParseError(state, 'expected identifier at ' + start);
|
|
113
|
+
}
|
|
114
|
+
return chars.join('');
|
|
115
|
+
}
|
|
116
|
+
function parseNumber(state, content) {
|
|
117
|
+
const chars = [];
|
|
118
|
+
if (peek(state, content) === '-') {
|
|
119
|
+
chars.push(consumeChar(state, content));
|
|
120
|
+
}
|
|
121
|
+
while (/[0-9]/u.test(peek(state, content))) {
|
|
122
|
+
chars.push(consumeChar(state, content));
|
|
123
|
+
}
|
|
124
|
+
if (peek(state, content) === '.') {
|
|
125
|
+
chars.push(consumeChar(state, content));
|
|
126
|
+
if (!/[0-9]/u.test(peek(state, content))) {
|
|
127
|
+
throwParseError(state, 'invalid numeric literal');
|
|
128
|
+
}
|
|
129
|
+
while (/[0-9]/u.test(peek(state, content))) {
|
|
130
|
+
chars.push(consumeChar(state, content));
|
|
131
|
+
}
|
|
132
|
+
}
|
|
133
|
+
const value = Number(chars.join(''));
|
|
134
|
+
if (!Number.isFinite(value)) {
|
|
135
|
+
throwParseError(state, 'invalid numeric literal');
|
|
136
|
+
}
|
|
137
|
+
return value;
|
|
138
|
+
}
|
|
139
|
+
function parseValue(state, content) {
|
|
140
|
+
skipWhitespaceAndComments(state, content);
|
|
141
|
+
const char = peek(state, content);
|
|
142
|
+
if (char === '{') {
|
|
143
|
+
return parseObject(state, content);
|
|
144
|
+
}
|
|
145
|
+
if (char === '[') {
|
|
146
|
+
return parseList(state, content);
|
|
147
|
+
}
|
|
148
|
+
if (char === '"' || char === "'") {
|
|
149
|
+
return parseString(state, content);
|
|
150
|
+
}
|
|
151
|
+
if (char === '-' || /[0-9]/u.test(char)) {
|
|
152
|
+
return parseNumber(state, content);
|
|
153
|
+
}
|
|
154
|
+
if (/[A-Za-z_]/u.test(char)) {
|
|
155
|
+
const identifier = parseIdentifier(state, content);
|
|
156
|
+
if (identifier === 'True')
|
|
157
|
+
return true;
|
|
158
|
+
if (identifier === 'False')
|
|
159
|
+
return false;
|
|
160
|
+
if (identifier === 'None')
|
|
161
|
+
return undefined;
|
|
162
|
+
throwParseError(state, `unsupported identifier '${identifier}'`);
|
|
163
|
+
}
|
|
164
|
+
throwParseError(state, `unexpected character '${char || 'EOF'}'`);
|
|
165
|
+
}
|
|
166
|
+
function expectChar(state, content, expected) {
|
|
167
|
+
skipWhitespaceAndComments(state, content);
|
|
168
|
+
const char = consumeChar(state, content);
|
|
169
|
+
if (char !== expected) {
|
|
170
|
+
throwParseError(state, `expected '${expected}' but found '${char || 'EOF'}'`);
|
|
171
|
+
}
|
|
172
|
+
}
|
|
173
|
+
function parseList(state, content) {
|
|
174
|
+
expectChar(state, content, '[');
|
|
175
|
+
const values = [];
|
|
176
|
+
skipWhitespaceAndComments(state, content);
|
|
177
|
+
if (peek(state, content) === ']') {
|
|
178
|
+
consumeChar(state, content);
|
|
179
|
+
return values;
|
|
180
|
+
}
|
|
181
|
+
while (state.index < content.length) {
|
|
182
|
+
const value = parseValue(state, content);
|
|
183
|
+
values.push(value);
|
|
184
|
+
skipWhitespaceAndComments(state, content);
|
|
185
|
+
if (peek(state, content) === ',') {
|
|
186
|
+
consumeChar(state, content);
|
|
187
|
+
skipWhitespaceAndComments(state, content);
|
|
188
|
+
if (peek(state, content) === ']') {
|
|
189
|
+
consumeChar(state, content);
|
|
190
|
+
return values;
|
|
191
|
+
}
|
|
192
|
+
continue;
|
|
193
|
+
}
|
|
194
|
+
if (peek(state, content) === ']') {
|
|
195
|
+
consumeChar(state, content);
|
|
196
|
+
return values;
|
|
197
|
+
}
|
|
198
|
+
throwParseError(state, "expected ',' or ']'");
|
|
199
|
+
}
|
|
200
|
+
throwParseError(state, 'unterminated list literal');
|
|
201
|
+
}
|
|
202
|
+
function parseObject(state, content) {
|
|
203
|
+
expectChar(state, content, '{');
|
|
204
|
+
const manifest = {};
|
|
205
|
+
skipWhitespaceAndComments(state, content);
|
|
206
|
+
if (peek(state, content) === '}') {
|
|
207
|
+
consumeChar(state, content);
|
|
208
|
+
return manifest;
|
|
209
|
+
}
|
|
210
|
+
while (state.index < content.length) {
|
|
211
|
+
skipWhitespaceAndComments(state, content);
|
|
212
|
+
const key = parseManifestKey(state, content);
|
|
213
|
+
skipWhitespaceAndComments(state, content);
|
|
214
|
+
expectChar(state, content, ':');
|
|
215
|
+
const value = parseValue(state, content);
|
|
216
|
+
manifest[key] = value;
|
|
217
|
+
skipWhitespaceAndComments(state, content);
|
|
218
|
+
if (peek(state, content) === ',') {
|
|
219
|
+
consumeChar(state, content);
|
|
220
|
+
skipWhitespaceAndComments(state, content);
|
|
221
|
+
if (peek(state, content) === '}') {
|
|
222
|
+
consumeChar(state, content);
|
|
223
|
+
return manifest;
|
|
224
|
+
}
|
|
225
|
+
continue;
|
|
226
|
+
}
|
|
227
|
+
if (peek(state, content) === '}') {
|
|
228
|
+
consumeChar(state, content);
|
|
229
|
+
return manifest;
|
|
230
|
+
}
|
|
231
|
+
throwParseError(state, "expected ',' or '}'");
|
|
232
|
+
}
|
|
233
|
+
throwParseError(state, 'unterminated object literal');
|
|
234
|
+
}
|
|
235
|
+
function parseManifestKey(state, content) {
|
|
236
|
+
skipWhitespaceAndComments(state, content);
|
|
237
|
+
const char = peek(state, content);
|
|
238
|
+
if (char !== '"' && char !== "'") {
|
|
239
|
+
throwParseError(state, 'manifest keys must be quoted');
|
|
240
|
+
}
|
|
241
|
+
return parseString(state, content);
|
|
242
|
+
}
|
|
243
|
+
function validateManifest(manifest) {
|
|
244
|
+
if (manifest.name !== undefined && typeof manifest.name !== 'string') {
|
|
245
|
+
throw new Error('invalid manifest: name must be a string');
|
|
246
|
+
}
|
|
247
|
+
if (manifest.version !== undefined && typeof manifest.version !== 'string') {
|
|
248
|
+
throw new Error('invalid manifest: version must be a string');
|
|
249
|
+
}
|
|
250
|
+
if (manifest.license !== undefined && typeof manifest.license !== 'string') {
|
|
251
|
+
throw new Error('invalid manifest: license must be a string');
|
|
252
|
+
}
|
|
253
|
+
if (manifest.application !== undefined && typeof manifest.application !== 'boolean') {
|
|
254
|
+
throw new Error('invalid manifest: application must be a boolean');
|
|
255
|
+
}
|
|
256
|
+
if (manifest.installable !== undefined && typeof manifest.installable !== 'boolean') {
|
|
257
|
+
throw new Error('invalid manifest: installable must be a boolean');
|
|
258
|
+
}
|
|
259
|
+
if (manifest.depends !== undefined && !Array.isArray(manifest.depends)) {
|
|
260
|
+
throw new Error('invalid manifest: depends must be a list of strings');
|
|
261
|
+
}
|
|
262
|
+
if (manifest.data !== undefined && !Array.isArray(manifest.data)) {
|
|
263
|
+
throw new Error('invalid manifest: data must be a list of strings');
|
|
264
|
+
}
|
|
265
|
+
if (Array.isArray(manifest.depends) && !manifest.depends.every((entry) => typeof entry === 'string')) {
|
|
266
|
+
throw new Error('invalid manifest: depends must be a list of strings');
|
|
267
|
+
}
|
|
268
|
+
if (Array.isArray(manifest.data) && !manifest.data.every((entry) => typeof entry === 'string')) {
|
|
269
|
+
throw new Error('invalid manifest: data must be a list of strings');
|
|
270
|
+
}
|
|
271
|
+
return manifest;
|
|
272
|
+
}
|
|
273
|
+
export function parseOdooManifest(content) {
|
|
274
|
+
try {
|
|
275
|
+
const { content: sourceContent, parser } = createParserState(content);
|
|
276
|
+
const manifest = parseValue(parser, sourceContent);
|
|
277
|
+
if (!manifest || typeof manifest !== 'object' || Array.isArray(manifest)) {
|
|
278
|
+
return { ok: false, error: 'Invalid manifest: top-level value must be a dictionary literal' };
|
|
279
|
+
}
|
|
280
|
+
skipWhitespaceAndComments(parser, sourceContent);
|
|
281
|
+
if (parser.index < sourceContent.length) {
|
|
282
|
+
return {
|
|
283
|
+
ok: false,
|
|
284
|
+
error: `Invalid manifest: trailing content after top-level object at line ${parser.line}, column ${parser.column}`,
|
|
285
|
+
};
|
|
286
|
+
}
|
|
287
|
+
return {
|
|
288
|
+
ok: true,
|
|
289
|
+
manifest: validateManifest(manifest),
|
|
290
|
+
};
|
|
291
|
+
}
|
|
292
|
+
catch (error) {
|
|
293
|
+
if (error instanceof Error) {
|
|
294
|
+
return { ok: false, error: error.message };
|
|
295
|
+
}
|
|
296
|
+
return { ok: false, error: 'Invalid manifest content' };
|
|
297
|
+
}
|
|
298
|
+
}
|