@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 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 widgets (npx navette).
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 widgets.
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 { generateResource, generateMigration, generatePage, generateWidget, studly, tableName } from "./generators.js";
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
- let password = flag('password') ?? (await ask('Password (leave empty to generate one)'));
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${generated ? ` — password: ${password}` : ''}`);
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() {
@@ -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
+ }
@@ -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.3",
4
- "description": "Command-line tools for Navette projects: migrations, users, and generators for resources, pages and widgets (npx navette).",
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.3",
46
- "@navette-js/database": "1.0.0-beta.3"
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",