@navette-js/cli 1.0.0-beta.3 → 1.0.0-beta.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/README.md +1 -1
- package/lib/cli.js +59 -5
- package/lib/generators.d.ts +6 -0
- package/lib/generators.js +89 -0
- package/lib/secret.d.ts +5 -0
- package/lib/secret.js +40 -0
- package/package.json +4 -4
package/README.md
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
# @navette-js/cli
|
|
2
2
|
|
|
3
|
-
Command-line tools for Navette projects: migrations, users, and generators for resources, pages and
|
|
3
|
+
Command-line tools for Navette projects: migrations, users, and generators for resources, pages, widgets and form fields (npx navette).
|
|
4
4
|
|
|
5
5
|
Part of [Navette](https://github.com/NavetteJS/navette), admin panels for Node.js. Start a project with `npm create navette@latest`, or install the framework with `npm install @navette-js/panels`.
|
|
6
6
|
|
package/lib/cli.js
CHANGED
|
@@ -7,14 +7,15 @@ var __rewriteRelativeImportExtension = (this && this.__rewriteRelativeImportExte
|
|
|
7
7
|
}
|
|
8
8
|
return path;
|
|
9
9
|
};
|
|
10
|
-
// Project CLI (`npx navette …`): migrations, users, and generators for resources, pages and
|
|
10
|
+
// Project CLI (`npx navette …`): migrations, users, and generators for resources, pages, widgets and form fields.
|
|
11
11
|
// Run it at the root of a project created with `npm create navette`.
|
|
12
12
|
import { existsSync, mkdirSync, readdirSync, writeFileSync } from 'node:fs';
|
|
13
13
|
import { randomBytes } from 'node:crypto';
|
|
14
14
|
import { join, relative } from 'node:path';
|
|
15
15
|
import { createInterface } from 'node:readline/promises';
|
|
16
16
|
import { pathToFileURL } from 'node:url';
|
|
17
|
-
import {
|
|
17
|
+
import { readSecret } from "./secret.js";
|
|
18
|
+
import { generateColumn, generateComponent, generateEntry, generateField, generateResource, generateMigration, generatePage, generateWidget, studly, tableName } from "./generators.js";
|
|
18
19
|
const cwd = process.cwd();
|
|
19
20
|
if (existsSync(join(cwd, '.env')))
|
|
20
21
|
process.loadEnvFile(join(cwd, '.env'));
|
|
@@ -46,6 +47,24 @@ async function ask(question, fallback = '') {
|
|
|
46
47
|
rl.close();
|
|
47
48
|
return answer || fallback;
|
|
48
49
|
}
|
|
50
|
+
async function askSecret(question) {
|
|
51
|
+
return process.stdin.isTTY ? readSecret(` ${question}: `) : '';
|
|
52
|
+
}
|
|
53
|
+
/** A password typed twice; empty: `''`, one is generated. */
|
|
54
|
+
async function askPassword() {
|
|
55
|
+
for (;;) {
|
|
56
|
+
const password = await askSecret('Password (at least 8 characters; leave empty to generate one)');
|
|
57
|
+
if (!password)
|
|
58
|
+
return '';
|
|
59
|
+
if (password.length < 8) {
|
|
60
|
+
say(' ✖ At least 8 characters: try again.');
|
|
61
|
+
continue;
|
|
62
|
+
}
|
|
63
|
+
if ((await askSecret('Confirm the password')) === password)
|
|
64
|
+
return password;
|
|
65
|
+
say(' ✖ The passwords do not match: try again.');
|
|
66
|
+
}
|
|
67
|
+
}
|
|
49
68
|
/** Never overwrites an existing file unless `--force` is given. */
|
|
50
69
|
function write(file, content) {
|
|
51
70
|
if (existsSync(file) && !flags.force)
|
|
@@ -79,7 +98,7 @@ const commands = {
|
|
|
79
98
|
},
|
|
80
99
|
},
|
|
81
100
|
'make:user': {
|
|
82
|
-
usage: 'navette make:user [--admin] [--name=] [--email=] [--password=] Create a user (role admin with --admin)',
|
|
101
|
+
usage: 'navette make:user [--admin] [--name=] [--email=] [--password=] Create a user (role admin with --admin; the password may also come from NAVETTE_PASSWORD)',
|
|
83
102
|
async run() {
|
|
84
103
|
const { db, migrate } = await project();
|
|
85
104
|
await migrate();
|
|
@@ -87,7 +106,8 @@ const commands = {
|
|
|
87
106
|
const email = flag('email') ?? (await ask('Email address', 'admin@example.com'));
|
|
88
107
|
if (!/^[^\s@]+@[^\s@]+$/.test(email))
|
|
89
108
|
fail(`Invalid email address: ${email}`);
|
|
90
|
-
|
|
109
|
+
// `--password` is visible to the other users of the machine (process list): prefer NAVETTE_PASSWORD, or typing it.
|
|
110
|
+
let password = flag('password') ?? process.env.NAVETTE_PASSWORD ?? (await askPassword());
|
|
91
111
|
const generated = !password;
|
|
92
112
|
if (generated)
|
|
93
113
|
password = randomBytes(12).toString('base64url');
|
|
@@ -99,7 +119,9 @@ const commands = {
|
|
|
99
119
|
const user = await knexAuthProvider((table) => db(table)).create({ name, email, password });
|
|
100
120
|
const now = new Date().toISOString().slice(0, 19).replace('T', ' ');
|
|
101
121
|
await db('users').where('id', user.id).update({ role: flags.admin ? 'admin' : 'user', email_verified_at: now });
|
|
102
|
-
say(` ✔ ${flags.admin ? 'Admin' : 'User'} ${email} created
|
|
122
|
+
say(` ✔ ${flags.admin ? 'Admin' : 'User'} ${email} created`);
|
|
123
|
+
if (generated)
|
|
124
|
+
say(` Password: ${password} (generated: copy it now, it is not shown again)`);
|
|
103
125
|
await db.destroy();
|
|
104
126
|
},
|
|
105
127
|
},
|
|
@@ -132,6 +154,38 @@ const commands = {
|
|
|
132
154
|
write(join(cwd, 'src', panelOf(), 'pages', `${name}.ts`), generatePage(name));
|
|
133
155
|
},
|
|
134
156
|
},
|
|
157
|
+
'make:field': {
|
|
158
|
+
usage: 'navette make:field <Name> Create a custom form field (src/fields)',
|
|
159
|
+
async run() {
|
|
160
|
+
const name = studly(positional[0] ?? fail('Give the field a name, e.g. RatingInput'));
|
|
161
|
+
write(join(cwd, 'src', 'fields', `${name}.ts`), generateField(name));
|
|
162
|
+
},
|
|
163
|
+
},
|
|
164
|
+
'make:entry': {
|
|
165
|
+
usage: 'navette make:entry <Name> Create a custom infolist entry (src/entries)',
|
|
166
|
+
async run() {
|
|
167
|
+
const name = studly(positional[0] ?? fail('Give the entry a name, e.g. AudioPlayerEntry'));
|
|
168
|
+
write(join(cwd, 'src', 'entries', `${name}.ts`), generateEntry(name));
|
|
169
|
+
},
|
|
170
|
+
},
|
|
171
|
+
'make:column': {
|
|
172
|
+
usage: 'navette make:column <Name> Create a custom table column (src/columns)',
|
|
173
|
+
async run() {
|
|
174
|
+
const name = studly(positional[0] ?? fail('Give the column a name, e.g. ProgressColumn'));
|
|
175
|
+
write(join(cwd, 'src', 'columns', `${name}.ts`), generateColumn(name));
|
|
176
|
+
},
|
|
177
|
+
},
|
|
178
|
+
'make:component': {
|
|
179
|
+
usage: 'navette make:component <name> Create a view component, used as <x-name> (src/components)',
|
|
180
|
+
async run() {
|
|
181
|
+
const raw = positional[0] ?? fail('Give the component a name, e.g. stat-card or forms.hint');
|
|
182
|
+
const name = raw.split('.').map((part) => part.replace(/([a-z0-9])([A-Z])/g, '$1-$2').replace(/[^A-Za-z0-9-]+/g, '-').toLowerCase().replace(/^-|-$/g, '')).filter(Boolean).join('.');
|
|
183
|
+
if (!name)
|
|
184
|
+
fail(`Invalid component name: ${raw}`);
|
|
185
|
+
write(join(cwd, 'src', 'components', ...name.split('.')) + '.ejs', generateComponent(name));
|
|
186
|
+
say(`\n Use it in any view as <x-${name}>…</x-${name}>.`);
|
|
187
|
+
},
|
|
188
|
+
},
|
|
135
189
|
'make:widget': {
|
|
136
190
|
usage: 'navette make:widget <Name> [--panel=admin] [--type=stats|chart|table] Create a dashboard widget',
|
|
137
191
|
async run() {
|
package/lib/generators.d.ts
CHANGED
|
@@ -15,3 +15,9 @@ export declare function generateResource(model: string, table: string, columns:
|
|
|
15
15
|
export declare function generateMigration(name: string): string;
|
|
16
16
|
export declare function generatePage(name: string): string;
|
|
17
17
|
export declare function generateWidget(name: string, type: 'stats' | 'chart' | 'table'): string;
|
|
18
|
+
/** A custom form field: an Alpine.js control entangled with the field's state, validated on the server. */
|
|
19
|
+
export declare function generateField(name: string): string;
|
|
20
|
+
export declare function generateEntry(name: string): string;
|
|
21
|
+
export declare function generateColumn(name: string): string;
|
|
22
|
+
/** Component view: `<x-name>` in any view, after `views.components(directory)` (done by the generated server). */
|
|
23
|
+
export declare function generateComponent(name: string): string;
|
package/lib/generators.js
CHANGED
|
@@ -175,3 +175,92 @@ export class ${name} extends StatsOverviewWidget {
|
|
|
175
175
|
}
|
|
176
176
|
`;
|
|
177
177
|
}
|
|
178
|
+
/** A custom form field: an Alpine.js control entangled with the field's state, validated on the server. */
|
|
179
|
+
export function generateField(name) {
|
|
180
|
+
return `import { Field, type Rule } from '@navette-js/forms';
|
|
181
|
+
import { configured, e, js } from '@navette-js/support';
|
|
182
|
+
|
|
183
|
+
/**
|
|
184
|
+
* ${headline(snake(name))} field. Use it like any field: \`${name}.make('name').required()\`.
|
|
185
|
+
* The state comes from the browser: check its shape in getValidationRules() before it is saved.
|
|
186
|
+
*/
|
|
187
|
+
export class ${name} extends Field {
|
|
188
|
+
static make(name: string): ${name} {
|
|
189
|
+
return configured(new ${name}(name));
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
async getValidationRules(): Promise<Rule[]> {
|
|
193
|
+
return [
|
|
194
|
+
...(await super.getValidationRules()),
|
|
195
|
+
{ name: 'string', passes: (value) => typeof value === 'string', message: (attribute) => \`The \${attribute} field must be a string.\` },
|
|
196
|
+
];
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
async toHtml(): Promise<string> {
|
|
200
|
+
const id = this.getId();
|
|
201
|
+
const disabled = await this.isDisabled();
|
|
202
|
+
|
|
203
|
+
return this.wrap(
|
|
204
|
+
\`<div class="fi-input-wrp\${this.hasError() ? ' fi-invalid' : ''}" x-data="{ state: $wire.$entangle(\${e(js(this.getStatePath()))}) }">
|
|
205
|
+
<div class="fi-input-wrp-content-ctn">
|
|
206
|
+
<input id="\${e(id)}" type="text" class="fi-input" x-model="state"\${disabled ? ' disabled' : ''}\${this.hasError() ? \` aria-invalid="true" aria-describedby="\${e(id)}-error"\` : ''} />
|
|
207
|
+
</div>
|
|
208
|
+
</div>\`,
|
|
209
|
+
);
|
|
210
|
+
}
|
|
211
|
+
}
|
|
212
|
+
`;
|
|
213
|
+
}
|
|
214
|
+
export function generateEntry(name) {
|
|
215
|
+
return `import { ViewEntry } from '@navette-js/infolists';
|
|
216
|
+
import { configured, e } from '@navette-js/support';
|
|
217
|
+
|
|
218
|
+
/** ${headline(snake(name))} infolist entry: \`${name}.make('field')\`. */
|
|
219
|
+
export class ${name} extends ViewEntry {
|
|
220
|
+
static make(name: string): ${name} {
|
|
221
|
+
return configured(new ${name}(name));
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
async toHtml(): Promise<string> {
|
|
225
|
+
const state = await this.getState();
|
|
226
|
+
|
|
227
|
+
// \`wrap()\` adds the label and the entry wrapper. Escape every value you print with \`e()\`.
|
|
228
|
+
return this.wrap(\`<span>\${e(state ?? '')}</span>\`);
|
|
229
|
+
}
|
|
230
|
+
}
|
|
231
|
+
`;
|
|
232
|
+
}
|
|
233
|
+
export function generateColumn(name) {
|
|
234
|
+
return `import { configured, e } from '@navette-js/support';
|
|
235
|
+
import { Column } from '@navette-js/tables';
|
|
236
|
+
|
|
237
|
+
/** ${headline(snake(name))} table column: \`${name}.make('field')\`. */
|
|
238
|
+
export class ${name}<R = any> extends Column<R> {
|
|
239
|
+
static make<R = any>(name: string): ${name}<R> {
|
|
240
|
+
return configured(new ${name}<R>(name));
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
// Returns the HTML of the cell. Escape every value you print with \`e()\`.
|
|
244
|
+
async toHtml(record: R): Promise<string> {
|
|
245
|
+
const state = await this.getState(record);
|
|
246
|
+
|
|
247
|
+
return \`<div class="fi-ta-text"><span>\${e(state ?? '')}</span></div>\`;
|
|
248
|
+
}
|
|
249
|
+
}
|
|
250
|
+
`;
|
|
251
|
+
}
|
|
252
|
+
/** Component view: `<x-name>` in any view, after `views.components(directory)` (done by the generated server). */
|
|
253
|
+
export function generateComponent(name) {
|
|
254
|
+
return `<%#
|
|
255
|
+
<x-${name} title="…">content</x-${name}>
|
|
256
|
+
Variables: the tag's props (camelCased), \`slot\` (the content, already rendered), \`slots\` (named slots: <x-slot name="footer">),
|
|
257
|
+
\`attributes\` (class, id, data-*, x-*, wire:* attributes given on the tag).
|
|
258
|
+
Output tags: the escaping one (percent sign and equals) for values, the raw one (percent sign and dash) only for slots and trusted markup.
|
|
259
|
+
%>
|
|
260
|
+
<div <%- attributes.class(['${name}']) %>>
|
|
261
|
+
<% if (locals.title) { %><h3><%= title %></h3><% } %>
|
|
262
|
+
<%- slot %>
|
|
263
|
+
<% if (slots.footer) { %><footer><%- slots.footer %></footer><% } %>
|
|
264
|
+
</div>
|
|
265
|
+
`;
|
|
266
|
+
}
|
package/lib/secret.d.ts
ADDED
|
@@ -0,0 +1,5 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Reads a hidden answer in raw mode: each typed character shows as a dot, so the password never appears on screen.
|
|
3
|
+
* Backspace erases, Enter validates, Ctrl+C quits; escape sequences (arrow keys) are ignored.
|
|
4
|
+
*/
|
|
5
|
+
export declare function readSecret(label: string, input?: NodeJS.ReadStream, output?: NodeJS.WriteStream): Promise<string>;
|
package/lib/secret.js
ADDED
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
// Hidden terminal input, for passwords.
|
|
2
|
+
/**
|
|
3
|
+
* Reads a hidden answer in raw mode: each typed character shows as a dot, so the password never appears on screen.
|
|
4
|
+
* Backspace erases, Enter validates, Ctrl+C quits; escape sequences (arrow keys) are ignored.
|
|
5
|
+
*/
|
|
6
|
+
export function readSecret(label, input = process.stdin, output = process.stdout) {
|
|
7
|
+
output.write(label);
|
|
8
|
+
const wasRaw = input.isRaw === true;
|
|
9
|
+
input.setRawMode?.(true);
|
|
10
|
+
input.setEncoding('utf8');
|
|
11
|
+
input.resume();
|
|
12
|
+
return new Promise((resolve) => {
|
|
13
|
+
let value = '';
|
|
14
|
+
const onData = (chunk) => {
|
|
15
|
+
if (chunk.startsWith('\x1b'))
|
|
16
|
+
return;
|
|
17
|
+
for (const character of chunk) {
|
|
18
|
+
if (character === '\r' || character === '\n') {
|
|
19
|
+
input.off('data', onData);
|
|
20
|
+
input.setRawMode?.(wasRaw);
|
|
21
|
+
input.pause();
|
|
22
|
+
output.write('\n');
|
|
23
|
+
resolve(value);
|
|
24
|
+
return;
|
|
25
|
+
}
|
|
26
|
+
if (character === '\u0003') {
|
|
27
|
+
input.setRawMode?.(wasRaw);
|
|
28
|
+
output.write('\n');
|
|
29
|
+
process.exit(130);
|
|
30
|
+
}
|
|
31
|
+
if (character === '\u007f' || character === '\b')
|
|
32
|
+
value = value.slice(0, -1);
|
|
33
|
+
else if (character >= ' ')
|
|
34
|
+
value += character;
|
|
35
|
+
}
|
|
36
|
+
output.write(`\r\x1b[K${label}${'•'.repeat(value.length)}`);
|
|
37
|
+
};
|
|
38
|
+
input.on('data', onData);
|
|
39
|
+
});
|
|
40
|
+
}
|
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@navette-js/cli",
|
|
3
|
-
"version": "1.0.0-beta.
|
|
4
|
-
"description": "Command-line tools for Navette projects: migrations, users, and generators for resources, pages and
|
|
3
|
+
"version": "1.0.0-beta.5",
|
|
4
|
+
"description": "Command-line tools for Navette projects: migrations, users, and generators for resources, pages, widgets and form fields (npx navette).",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"type": "module",
|
|
7
7
|
"exports": {
|
|
@@ -42,8 +42,8 @@
|
|
|
42
42
|
"scaffold"
|
|
43
43
|
],
|
|
44
44
|
"dependencies": {
|
|
45
|
-
"@navette-js/panels": "1.0.0-beta.
|
|
46
|
-
"@navette-js/database": "1.0.0-beta.
|
|
45
|
+
"@navette-js/panels": "1.0.0-beta.5",
|
|
46
|
+
"@navette-js/database": "1.0.0-beta.5"
|
|
47
47
|
},
|
|
48
48
|
"publishConfig": {
|
|
49
49
|
"access": "public",
|