create-odoo-module 1.0.0

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.
@@ -0,0 +1,329 @@
1
+ 'use strict';
2
+
3
+ const path = require('path');
4
+ const { writeFile } = require('../utils/file-system');
5
+ const logger = require('../utils/logger');
6
+
7
+ /**
8
+ * Generate Odoo XML views: Form, List, Kanban, Search, Menu.
9
+ */
10
+ async function generateUiLayer(targetDir, config) {
11
+ const odooDir = path.join(targetDir, 'odoo_module');
12
+ const s = config.moduleNameSnake;
13
+ const P = config.moduleNamePascal;
14
+ const M = config.moduleNameOdoo;
15
+ const L = config.moduleNameLabel;
16
+
17
+ logger.verbose('Generating Odoo XML views...');
18
+
19
+ await Promise.all([
20
+ writeFile(path.join(odooDir, 'views', `${s}_views.xml`), genViews(s, P, M, L, config)),
21
+ writeFile(path.join(odooDir, 'views', `${s}_menus.xml`), genMenus(s, P, M, L)),
22
+ ]);
23
+ }
24
+
25
+ // ─── Views generator ─────────────────────────────────────────────────────────
26
+
27
+ function genViews(s, P, M, L, config) {
28
+ return `<?xml version="1.0" encoding="utf-8"?>
29
+ <odoo>
30
+
31
+ <!-- ══════════════════════════════════════════════════════════════════════
32
+ SEARCH VIEW
33
+ ══════════════════════════════════════════════════════════════════ -->
34
+ <record id="view_${s}_search" model="ir.ui.view">
35
+ <field name="name">${M}.search</field>
36
+ <field name="model">${M}</field>
37
+ <field name="arch" type="xml">
38
+ <search string="Search ${L}">
39
+ <field name="name" string="Name" filter_domain="[('name','ilike',self)]"/>
40
+ <field name="reference" string="Reference"/>
41
+ <field name="user_id" string="Responsible"/>
42
+ <separator/>
43
+ <!-- Status filters -->
44
+ <filter name="filter_draft" string="Draft"
45
+ domain="[('state','=','draft')]"/>
46
+ <filter name="filter_confirmed" string="Confirmed"
47
+ domain="[('state','=','confirmed')]"/>
48
+ <filter name="filter_in_progress" string="In Progress"
49
+ domain="[('state','=','in_progress')]"/>
50
+ <filter name="filter_done" string="Done"
51
+ domain="[('state','=','done')]"/>
52
+ <filter name="filter_cancelled" string="Cancelled"
53
+ domain="[('state','=','cancelled')]"/>
54
+ <separator/>
55
+ <filter name="filter_my" string="My Records"
56
+ domain="[('user_id','=',uid)]"/>
57
+ <filter name="filter_today" string="Starting Today"
58
+ domain="[('date_start','=',context_today().strftime('%Y-%m-%d'))]"/>
59
+ <separator/>
60
+ <filter name="filter_archived" string="Archived"
61
+ domain="[('active','=',False)]"/>
62
+ <!-- Group by -->
63
+ <group expand="0" string="Group By">
64
+ <filter name="group_state" string="Status" context="{'group_by': 'state'}"/>
65
+ <filter name="group_user" string="Responsible" context="{'group_by': 'user_id'}"/>
66
+ <filter name="group_priority" string="Priority" context="{'group_by': 'priority'}"/>
67
+ <filter name="group_date_start" string="Start Date" context="{'group_by': 'date_start:month'}"/>
68
+ </group>
69
+ </search>
70
+ </field>
71
+ </record>
72
+
73
+ <!-- ══════════════════════════════════════════════════════════════════════
74
+ FORM VIEW
75
+ ══════════════════════════════════════════════════════════════════ -->
76
+ <record id="view_${s}_form" model="ir.ui.view">
77
+ <field name="name">${M}.form</field>
78
+ <field name="model">${M}</field>
79
+ <field name="arch" type="xml">
80
+ <form string="${L}">
81
+ <header>
82
+ <button name="action_confirm" string="Confirm" type="object"
83
+ class="btn-primary"
84
+ invisible="state != 'draft'"/>
85
+ <button name="action_start" string="Start" type="object"
86
+ class="btn-primary"
87
+ invisible="state != 'confirmed'"/>
88
+ <button name="action_done" string="Mark as Done" type="object"
89
+ class="btn-success"
90
+ invisible="state != 'in_progress'"/>
91
+ <button name="action_cancel" string="Cancel" type="object"
92
+ invisible="state in ('done','cancelled')"/>
93
+ <button name="action_reset_draft" string="Reset to Draft" type="object"
94
+ invisible="state != 'cancelled'"/>
95
+ <field name="state" widget="statusbar"
96
+ statusbar_visible="draft,confirmed,in_progress,done"/>
97
+ </header>
98
+ <sheet>
99
+ <!-- Priority stars -->
100
+ <div class="oe_button_box" name="button_box">
101
+ <button class="oe_stat_button" type="object"
102
+ name="action_open_form" icon="fa-info-circle">
103
+ <div class="o_stat_info">
104
+ <span class="o_stat_text">Details</span>
105
+ </div>
106
+ </button>
107
+ </div>
108
+ <widget name="web_ribbon" title="Archived" bg_color="text-bg-danger"
109
+ invisible="active"/>
110
+ <field name="active" invisible="1"/>
111
+ <field name="priority" widget="priority" class="me-3"/>
112
+ <div class="oe_title">
113
+ <label for="name" class="oe_edit_only"/>
114
+ <h1>
115
+ <field name="name" placeholder="Enter ${L} name…"
116
+ class="o_field_text"/>
117
+ </h1>
118
+ <h4 class="text-muted">
119
+ <field name="reference" readonly="1"/>
120
+ </h4>
121
+ </div>
122
+ <group>
123
+ <group string="General">
124
+ <field name="user_id"/>
125
+ <field name="company_id"
126
+ groups="base.group_multi_company"/>
127
+ <field name="tag_ids" widget="many2many_tags"
128
+ options="{'color_field': 'color'}"/>
129
+ </group>
130
+ <group string="Schedule">
131
+ <field name="date_start"/>
132
+ <field name="date_end"/>
133
+ <field name="duration_days" readonly="1"
134
+ invisible="not date_start or not date_end"/>
135
+ </group>
136
+ </group>
137
+ <notebook>
138
+ <page string="Description" name="description">
139
+ <field name="description" placeholder="Add a description…"/>
140
+ </page>
141
+ </notebook>
142
+ </sheet>
143
+ <!-- Chatter -->
144
+ <div class="oe_chatter">
145
+ <field name="message_follower_ids"/>
146
+ <field name="activity_ids"/>
147
+ <field name="message_ids"/>
148
+ </div>
149
+ </form>
150
+ </field>
151
+ </record>
152
+
153
+ <!-- ══════════════════════════════════════════════════════════════════════
154
+ LIST VIEW
155
+ ══════════════════════════════════════════════════════════════════ -->
156
+ <record id="view_${s}_list" model="ir.ui.view">
157
+ <field name="name">${M}.list</field>
158
+ <field name="model">${M}</field>
159
+ <field name="arch" type="xml">
160
+ <list string="${L}"
161
+ decoration-muted="state == 'cancelled'"
162
+ decoration-success="state == 'done'"
163
+ decoration-warning="state == 'in_progress'">
164
+ <field name="priority" widget="priority"/>
165
+ <field name="reference" readonly="1"/>
166
+ <field name="name"/>
167
+ <field name="user_id" optional="show"/>
168
+ <field name="date_start" optional="show"/>
169
+ <field name="date_end" optional="show"/>
170
+ <field name="duration_days" string="Days" optional="hide"/>
171
+ <field name="state" widget="badge"
172
+ decoration-info="state == 'draft'"
173
+ decoration-primary="state == 'confirmed'"
174
+ decoration-warning="state == 'in_progress'"
175
+ decoration-success="state == 'done'"
176
+ decoration-danger="state == 'cancelled'"/>
177
+ <!-- Inline actions -->
178
+ <button name="action_confirm" string="Confirm" type="object"
179
+ icon="fa-check" invisible="state != 'draft'"
180
+ title="Confirm this record"/>
181
+ <button name="action_done" string="Done" type="object"
182
+ icon="fa-flag-checkered" invisible="state != 'in_progress'"
183
+ title="Mark as done"/>
184
+ </list>
185
+ </field>
186
+ </record>
187
+
188
+ <!-- ══════════════════════════════════════════════════════════════════════
189
+ KANBAN VIEW
190
+ ══════════════════════════════════════════════════════════════════ -->
191
+ <record id="view_${s}_kanban" model="ir.ui.view">
192
+ <field name="name">${M}.kanban</field>
193
+ <field name="model">${M}</field>
194
+ <field name="arch" type="xml">
195
+ <kanban default_group_by="state"
196
+ class="o_kanban_small_column"
197
+ quick_create="false">
198
+ <field name="id"/>
199
+ <field name="name"/>
200
+ <field name="state"/>
201
+ <field name="priority"/>
202
+ <field name="user_id"/>
203
+ <field name="date_start"/>
204
+ <field name="date_end"/>
205
+ <field name="color"/>
206
+ <templates>
207
+ <t t-name="card" class="oe_kanban_card oe_kanban_global_click">
208
+ <div class="o_kanban_record_top">
209
+ <div class="o_kanban_record_headings">
210
+ <strong class="o_kanban_record_title">
211
+ <field name="name"/>
212
+ </strong>
213
+ </div>
214
+ <field name="priority" widget="priority"/>
215
+ </div>
216
+ <div class="o_kanban_record_body">
217
+ <div t-if="record.date_start.raw_value" class="text-muted small">
218
+ <i class="fa fa-calendar me-1"/>
219
+ <field name="date_start"/>
220
+ <t t-if="record.date_end.raw_value">
221
+ → <field name="date_end"/>
222
+ </t>
223
+ </div>
224
+ </div>
225
+ <div class="o_kanban_record_bottom">
226
+ <div class="oe_kanban_bottom_left">
227
+ <field name="state" widget="badge"/>
228
+ </div>
229
+ <div class="oe_kanban_bottom_right">
230
+ <field name="user_id" widget="many2one_avatar_user"/>
231
+ </div>
232
+ </div>
233
+ </t>
234
+ </templates>
235
+ </kanban>
236
+ </field>
237
+ </record>
238
+
239
+ <!-- ══════════════════════════════════════════════════════════════════════
240
+ ACTIVITY VIEW
241
+ ══════════════════════════════════════════════════════════════════ -->
242
+ <record id="view_${s}_activity" model="ir.ui.view">
243
+ <field name="name">${M}.activity</field>
244
+ <field name="model">${M}</field>
245
+ <field name="arch" type="xml">
246
+ <activity string="${L}">
247
+ <field name="user_id"/>
248
+ <templates>
249
+ <div t-name="activity-box">
250
+ <img t-att-src="activity_image('res.users','avatar_128',record.user_id.raw_value)"
251
+ role="img" t-att-title="record.user_id.value"
252
+ t-att-alt="record.user_id.value"/>
253
+ <div>
254
+ <field name="name" display="full"/>
255
+ <field name="state" muted="1" display="full"/>
256
+ </div>
257
+ </div>
258
+ </templates>
259
+ </activity>
260
+ </field>
261
+ </record>
262
+
263
+ <!-- ══════════════════════════════════════════════════════════════════════
264
+ ACTION WINDOW
265
+ ══════════════════════════════════════════════════════════════════ -->
266
+ <record id="action_${s}" model="ir.actions.act_window">
267
+ <field name="name">${L}</field>
268
+ <field name="res_model">${M}</field>
269
+ <field name="view_mode">list,kanban,form,activity</field>
270
+ <field name="search_view_id" ref="view_${s}_search"/>
271
+ <field name="context">{
272
+ 'search_default_filter_my': 0
273
+ }</field>
274
+ <field name="help" type="html">
275
+ <p class="o_view_nocontent_smiling_face">
276
+ Create your first ${L}!
277
+ </p>
278
+ <p>
279
+ Generated by <a href="https://create-odoo-module.dev" target="_blank">create-odoo-module</a>.
280
+ </p>
281
+ </field>
282
+ </record>
283
+
284
+ </odoo>
285
+ `;
286
+ }
287
+
288
+ function genMenus(s, P, M, L) {
289
+ return `<?xml version="1.0" encoding="utf-8"?>
290
+ <odoo>
291
+
292
+ <!-- ══════════════════════════════════════════════════════════════════════
293
+ TOP-LEVEL MENU
294
+ ══════════════════════════════════════════════════════════════════ -->
295
+ <menuitem
296
+ id="menu_${s}_root"
297
+ name="${L}"
298
+ sequence="50"
299
+ web_icon="${s},static/description/icon.png"/>
300
+
301
+ <!-- ══════════════════════════════════════════════════════════════════════
302
+ SUB-MENUS
303
+ ══════════════════════════════════════════════════════════════════ -->
304
+ <menuitem
305
+ id="menu_${s}_main"
306
+ name="${L}"
307
+ parent="menu_${s}_root"
308
+ sequence="10"/>
309
+
310
+ <menuitem
311
+ id="menu_${s}_list"
312
+ name="All ${L}s"
313
+ parent="menu_${s}_main"
314
+ action="action_${s}"
315
+ sequence="10"/>
316
+
317
+ <!-- Configuration menu (example) -->
318
+ <menuitem
319
+ id="menu_${s}_config"
320
+ name="Configuration"
321
+ parent="menu_${s}_root"
322
+ sequence="90"
323
+ groups="base.group_system"/>
324
+
325
+ </odoo>
326
+ `;
327
+ }
328
+
329
+ module.exports = { generateUiLayer };
@@ -0,0 +1,67 @@
1
+ 'use strict';
2
+
3
+ const fs = require('fs-extra');
4
+ const path = require('path');
5
+ const ejs = require('ejs');
6
+
7
+ /**
8
+ * Write a file, creating parent directories automatically.
9
+ */
10
+ async function writeFile(filePath, content) {
11
+ await fs.ensureDir(path.dirname(filePath));
12
+ await fs.writeFile(filePath, content, 'utf8');
13
+ }
14
+
15
+ /**
16
+ * Render an EJS template string and write the output file.
17
+ *
18
+ * @param {string} templatePath Absolute path to .ejs template
19
+ * @param {string} outputPath Destination file path
20
+ * @param {object} data Template variables
21
+ */
22
+ async function renderTemplate(templatePath, outputPath, data) {
23
+ const template = await fs.readFile(templatePath, 'utf8');
24
+ const rendered = ejs.render(template, data, {
25
+ filename: templatePath, // allows <%- include() %>
26
+ rmWhitespace: false,
27
+ });
28
+ await writeFile(outputPath, rendered);
29
+ }
30
+
31
+ /**
32
+ * Copy a static (non-EJS) file.
33
+ */
34
+ async function copyFile(src, dest) {
35
+ await fs.ensureDir(path.dirname(dest));
36
+ await fs.copy(src, dest);
37
+ }
38
+
39
+ /**
40
+ * Copy an entire directory.
41
+ */
42
+ async function copyDir(src, dest) {
43
+ await fs.copy(src, dest, { overwrite: true });
44
+ }
45
+
46
+ /**
47
+ * Create directory (recursive).
48
+ */
49
+ async function mkdirp(dirPath) {
50
+ await fs.ensureDir(dirPath);
51
+ }
52
+
53
+ /**
54
+ * Check if a path exists.
55
+ */
56
+ async function exists(p) {
57
+ return fs.pathExists(p);
58
+ }
59
+
60
+ /**
61
+ * Remove a path (file or dir).
62
+ */
63
+ async function remove(p) {
64
+ return fs.remove(p);
65
+ }
66
+
67
+ module.exports = { writeFile, renderTemplate, copyFile, copyDir, mkdirp, exists, remove };
@@ -0,0 +1,57 @@
1
+ 'use strict';
2
+
3
+ const axios = require('axios');
4
+ const chalk = require('chalk');
5
+ const logger = require('./logger');
6
+
7
+ const LICENSE_API = 'https://create-odoo-module.dev/api/v1/license/verify';
8
+ const CACHE_TTL = 1000 * 60 * 60 * 24; // 24 h
9
+
10
+ let _cachedResult = null;
11
+ let _cacheTime = 0;
12
+
13
+ /**
14
+ * Verify a Pro license key against the API.
15
+ * Returns true if valid, false otherwise.
16
+ * Results are cached for 24 hours per process.
17
+ *
18
+ * @param {string|null} key
19
+ * @returns {Promise<boolean>}
20
+ */
21
+ async function verifyProLicense(key) {
22
+ if (!key) return false;
23
+
24
+ // Cache hit
25
+ if (_cachedResult !== null && Date.now() - _cacheTime < CACHE_TTL) {
26
+ return _cachedResult;
27
+ }
28
+
29
+ try {
30
+ logger.verbose(`Verifying Pro license key: ${key.slice(0, 8)}...`);
31
+
32
+ const response = await axios.post(
33
+ LICENSE_API,
34
+ { key },
35
+ { timeout: 5000, headers: { 'User-Agent': 'create-odoo-module-cli' } }
36
+ );
37
+
38
+ const valid = response.data?.valid === true;
39
+ _cachedResult = valid;
40
+ _cacheTime = Date.now();
41
+
42
+ if (valid) {
43
+ logger.success(chalk.green('Pro license verified ✓'));
44
+ } else {
45
+ logger.warn(`Invalid Pro key. Get your key at ${chalk.underline('https://create-odoo-module.dev/pro')}`);
46
+ }
47
+
48
+ return valid;
49
+ } catch (err) {
50
+ // Network error — fail open (allow generation without Pro features)
51
+ logger.verbose(`License check failed (network): ${err.message}`);
52
+ logger.warn('Could not reach license server — Pro templates skipped');
53
+ return false;
54
+ }
55
+ }
56
+
57
+ module.exports = { verifyProLicense };
@@ -0,0 +1,32 @@
1
+ 'use strict';
2
+
3
+ const chalk = require('chalk');
4
+ const figures = require('figures');
5
+
6
+ let verboseMode = false;
7
+
8
+ const logger = {
9
+ setVerbose(v) { verboseMode = v; },
10
+ isVerbose() { return verboseMode; },
11
+
12
+ info (msg) { console.log(` ${chalk.cyan(figures.info)} ${msg}`); },
13
+ success(msg) { console.log(` ${chalk.green(figures.tick)} ${msg}`); },
14
+ warn (msg) { console.warn(` ${chalk.yellow(figures.warning)} ${chalk.yellow(msg)}`); },
15
+ error (msg) { console.error(` ${chalk.red(figures.cross)} ${chalk.red(msg)}`); },
16
+ dim (msg) { console.log(chalk.dim(` ${msg}`)); },
17
+ blank () { console.log(); },
18
+
19
+ verbose(msg) {
20
+ if (verboseMode) {
21
+ console.log(chalk.dim(` [verbose] ${msg}`));
22
+ }
23
+ },
24
+
25
+ step(index, total, label) {
26
+ console.log(
27
+ ` ${chalk.dim(`[${index}/${total}]`)} ${chalk.bold(label)}`
28
+ );
29
+ },
30
+ };
31
+
32
+ module.exports = logger;
@@ -0,0 +1,32 @@
1
+ 'use strict';
2
+
3
+ const ora = require('ora');
4
+
5
+ /**
6
+ * Create a pre-configured spinner instance.
7
+ */
8
+ function createSpinner(text = '') {
9
+ return ora({
10
+ text,
11
+ spinner: 'dots2',
12
+ color: 'magenta',
13
+ });
14
+ }
15
+
16
+ /**
17
+ * Wrap an async function with a spinner.
18
+ * Shows `startText` while running, `doneText` on success.
19
+ */
20
+ async function withSpinner(startText, doneText, fn) {
21
+ const spinner = createSpinner(startText).start();
22
+ try {
23
+ const result = await fn();
24
+ spinner.succeed(doneText || startText);
25
+ return result;
26
+ } catch (err) {
27
+ spinner.fail(`Failed: ${err.message}`);
28
+ throw err;
29
+ }
30
+ }
31
+
32
+ module.exports = { createSpinner, withSpinner };
@@ -0,0 +1,65 @@
1
+ 'use strict';
2
+
3
+ /**
4
+ * fleet-manager → fleet_manager
5
+ * FleetManager → fleet_manager
6
+ */
7
+ function toSnakeCase(str) {
8
+ return str
9
+ .trim()
10
+ .replace(/[-\s]+/g, '_')
11
+ .replace(/([A-Z])/g, (m, l) => '_' + l.toLowerCase())
12
+ .replace(/^_/, '')
13
+ .toLowerCase();
14
+ }
15
+
16
+ /**
17
+ * fleet-manager → FleetManager
18
+ * fleet_manager → FleetManager
19
+ */
20
+ function toPascalCase(str) {
21
+ return str
22
+ .trim()
23
+ .replace(/[-_\s]+(.)/g, (_, c) => c.toUpperCase())
24
+ .replace(/^(.)/, (m, c) => c.toUpperCase());
25
+ }
26
+
27
+ /**
28
+ * FleetManager → fleet-manager
29
+ * fleet_manager → fleet-manager
30
+ */
31
+ function toKebabCase(str) {
32
+ return str
33
+ .trim()
34
+ .replace(/([A-Z])/g, (m, l) => '-' + l.toLowerCase())
35
+ .replace(/[_\s]+/g, '-')
36
+ .replace(/^-/, '')
37
+ .toLowerCase();
38
+ }
39
+
40
+ /**
41
+ * fleet-manager → Fleet Manager
42
+ */
43
+ function toTitleCase(str) {
44
+ return toKebabCase(str)
45
+ .split('-')
46
+ .map(w => w.charAt(0).toUpperCase() + w.slice(1))
47
+ .join(' ');
48
+ }
49
+
50
+ /**
51
+ * fleet.manager (Odoo technical name, dot-notation)
52
+ * fleet-manager → fleet.manager
53
+ */
54
+ function toOdooModel(str) {
55
+ return toSnakeCase(str).replace(/_/g, '.');
56
+ }
57
+
58
+ /**
59
+ * Pad string on the right to a given length
60
+ */
61
+ function padRight(str, len, char = ' ') {
62
+ return String(str).padEnd(len, char);
63
+ }
64
+
65
+ module.exports = { toSnakeCase, toPascalCase, toKebabCase, toTitleCase, toOdooModel, padRight };