create-navette 1.0.0-beta.4 → 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/lib/index.js CHANGED
@@ -7,6 +7,7 @@ import { existsSync, mkdirSync, readdirSync, readFileSync, writeFileSync } from
7
7
  import { dirname, join, resolve } from 'node:path';
8
8
  import { createInterface } from 'node:readline/promises';
9
9
  import { fileURLToPath } from 'node:url';
10
+ import { readSecret } from "./secret.js";
10
11
  import { projectFiles } from "./templates.js";
11
12
  const own = JSON.parse(readFileSync(join(dirname(fileURLToPath(import.meta.url)), '..', 'package.json'), 'utf8'));
12
13
  const args = process.argv.slice(2);
@@ -34,6 +35,27 @@ async function text(question, fallback, given) {
34
35
  return fallback;
35
36
  return (await prompt().question(` ${bold(question)} ${dim(`(${fallback})`)} `)).trim() || fallback;
36
37
  }
38
+ async function secret(question) {
39
+ // Readline would echo the characters: it is closed while the password is typed (`prompt()` opens a new one after).
40
+ rl?.close();
41
+ rl = null;
42
+ return readSecret(` ${bold(question)} `);
43
+ }
44
+ /** The admin's password, typed twice; empty: `null`, one is generated. */
45
+ async function choosePassword() {
46
+ for (;;) {
47
+ const password = await secret(`Admin password ${dim('(at least 8 characters; leave empty to generate one)')}`);
48
+ if (!password)
49
+ return null;
50
+ if (password.length < 8) {
51
+ say(` ${dim('✖')} At least 8 characters: try again.`);
52
+ continue;
53
+ }
54
+ if ((await secret('Confirm the password')) === password)
55
+ return password;
56
+ say(` ${dim('✖')} The passwords do not match: try again.`);
57
+ }
58
+ }
37
59
  async function choose(question, choices, fallback, given) {
38
60
  if (given !== undefined) {
39
61
  const match = choices.find(([value]) => value === given);
@@ -70,6 +92,11 @@ const database = await choose('Database', [['sqlite', 'SQLite — a file, nothin
70
92
  const locale = await choose('Language of the interface', [['en', 'English'], ['fr', 'Français']], 'en', flag('locale'));
71
93
  const install = await confirm('Install the dependencies now?', true, flags['no-install'] ? 'no' : flags.install);
72
94
  const adminEmail = install && database === 'sqlite' ? await text('Admin email address', 'admin@example.com', flag('admin-email')) : null;
95
+ // The admin's password: `--admin-password`, typed twice, or generated (and then shown at the end).
96
+ const givenPassword = flag('admin-password');
97
+ if (givenPassword !== undefined && givenPassword.length < 8)
98
+ fail('--admin-password must be at least 8 characters.');
99
+ const chosenPassword = adminEmail ? (givenPassword ?? (interactive ? await choosePassword() : null)) : null;
73
100
  rl?.close();
74
101
  const options = {
75
102
  name: name.toLowerCase(), appName: name.replace(/[-_.]+/g, ' ').replace(/\b\w/g, (c) => c.toUpperCase()),
@@ -97,13 +124,16 @@ for (const [file, content] of Object.entries(files)) {
97
124
  say(`\n ✔ Created ${name} (${Object.keys(files).length} files)`);
98
125
  const run = (command, commandArgs) => spawnSync(command, commandArgs, { cwd: target, stdio: 'inherit', shell: process.platform === 'win32' }).status === 0;
99
126
  let adminPassword = null;
127
+ const passwordGenerated = adminEmail !== null && chosenPassword === null;
100
128
  if (install) {
101
129
  say(' … Installing dependencies\n');
102
130
  if (!run('npm', ['install', '--no-fund', '--no-audit']))
103
131
  fail('npm install failed: fix the error above, then run `npm install` in the project.');
104
132
  if (adminEmail) {
105
- adminPassword = flag('admin-password') ?? randomBytes(12).toString('base64url');
106
- if (!run('npx', ['navette', 'make:user', '--admin', '--name=Admin', `--email=${adminEmail}`, `--password=${adminPassword}`]))
133
+ adminPassword = chosenPassword ?? randomBytes(12).toString('base64url');
134
+ // Passed through the environment, not the command line, where other users of the machine could read it.
135
+ const created = spawnSync('npx', ['navette', 'make:user', '--admin', '--name=Admin', `--email=${adminEmail}`], { cwd: target, stdio: 'inherit', shell: process.platform === 'win32', env: { ...process.env, NAVETTE_PASSWORD: adminPassword } }).status === 0;
136
+ if (!created)
107
137
  fail('Creating the admin user failed: run `npx navette make:user --admin` in the project.');
108
138
  }
109
139
  }
@@ -116,7 +146,14 @@ if (database !== 'sqlite')
116
146
  if (!adminPassword)
117
147
  say(' npx navette make:user --admin');
118
148
  say(' npm run dev\n');
119
- say(` Admin panel http://127.0.0.1:${options.port}/admin${adminPassword ? ` ${dim('sign in:')} ${adminEmail} / ${adminPassword}` : ''}`);
149
+ say(` Admin panel http://127.0.0.1:${options.port}/admin`);
120
150
  if (userPanel)
121
151
  say(` User panel http://127.0.0.1:${options.port}/app`);
152
+ if (adminPassword) {
153
+ say(`\n ${bold('Admin account')}\n`);
154
+ say(` Email ${adminEmail}`);
155
+ say(passwordGenerated
156
+ ? ` Password ${bold(adminPassword)} ${dim('generated: copy it now, it is not shown again')}`
157
+ : ` Password ${dim('the one you chose')}`);
158
+ }
122
159
  say(`\n Generate code with ${bold('npx navette make:resource Post --generate')} — see README.md.\n`);
@@ -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/lib/templates.js CHANGED
@@ -101,6 +101,14 @@ export async function up(db: Knex): Promise<void> {
101
101
  table.timestamps();
102
102
  });
103
103
  }
104
+ `;
105
+ files['src/migrations/0006_add_multi_factor_authentication.ts'] = `import type { Knex } from '@navette-js/database';
106
+ import { multiFactorAuthenticationSchema } from '@navette-js/panels';
107
+
108
+ // Two-factor authentication: authenticator app secret and recovery codes (users table), and a security_keys table.
109
+ export async function up(db: Knex): Promise<void> {
110
+ await multiFactorAuthenticationSchema(db);
111
+ }
104
112
  `;
105
113
  files['src/migrations/0002_create_notifications_table.ts'] = `import type { Knex } from '@navette-js/database';
106
114
 
@@ -214,7 +222,7 @@ ${timestamps}
214
222
  `;
215
223
  // ------------------------------------------------------------------ admin panel
216
224
  files['src/panels/admin.ts'] = `// The admin panel (/admin): only users with the \`admin\` role can sign in.
217
- import { AccountWidget, discoverPages, discoverResources, discoverWidgets, fileMailer, ${sqlite ? 'knexNotificationStore, ' : ''}Panel } from '@navette-js/panels';
225
+ import { AccountWidget, AppAuthentication, discoverPages, EditProfile, EmailAuthentication, discoverResources, discoverWidgets, fileMailer, ${sqlite ? 'knexNotificationStore, ' : ''}Panel, SecurityKeyAuthentication } from '@navette-js/panels';
218
226
  import { Color } from '@navette-js/support';
219
227
  import { db } from '../database.ts';
220
228
  import { users } from '../users.ts';
@@ -228,7 +236,10 @@ export default Panel.make()
228
236
  .setColors({ primary: Color.Amber })
229
237
  .login()
230
238
  .passwordReset()
231
- .profile()
239
+ // Profile page in the full layout (sections, with the navigation).
240
+ .profile(EditProfile, false)
241
+ // Two-factor authentication, set up by each user on their profile: an authenticator app (with recovery codes), a code by email or a security key.
242
+ .multiFactorAuthentication([AppAuthentication.make().recoverable(), EmailAuthentication.make(), SecurityKeyAuthentication.make()])
232
243
  .mailer(fileMailer(process.env.MAIL_LOG ?? 'storage/mail.log'))
233
244
  ${sqlite ? ' .databaseNotifications(knexNotificationStore((table: string) => db(table)))\n' : ''} .globalSearchKeyBindings(['command+k', 'ctrl+k'])
234
245
  // Resources, pages and widgets are discovered from these folders: \`npx navette make:resource\` adds files there.
@@ -383,7 +394,7 @@ export class TaskStats extends StatsOverviewWidget {
383
394
  // ------------------------------------------------------------------ user panel
384
395
  if (o.userPanel) {
385
396
  files['src/panels/app.ts'] = `// The user panel (/app): anyone can sign up${teams ? ', then create or join teams' : ''}.
386
- import { AccountWidget, discoverPages, discoverResources, discoverWidgets, fileMailer, ${teams ? 'knexTenantProvider, ' : ''}Panel } from '@navette-js/panels';
397
+ import { AccountWidget, AppAuthentication, discoverPages, EditProfile, EmailAuthentication, discoverResources, discoverWidgets, fileMailer, ${teams ? 'knexTenantProvider, ' : ''}Panel, SecurityKeyAuthentication } from '@navette-js/panels';
387
398
  import { db } from '../database.ts';
388
399
  import { users } from '../users.ts';
389
400
  ${teams ? "import { EditTeamProfile, RegisterTeam } from '../app/tenancy.ts';\n" : ''}
@@ -397,7 +408,10 @@ export default Panel.make()
397
408
  .registration()
398
409
  .passwordReset()
399
410
  .emailVerification()
400
- .profile()
411
+ // Profile page in the full layout (sections, with the navigation).
412
+ .profile(EditProfile, false)
413
+ // Two-factor authentication, set up by each user on their profile: an authenticator app (with recovery codes), a code by email or a security key.
414
+ .multiFactorAuthentication([AppAuthentication.make().recoverable(), EmailAuthentication.make(), SecurityKeyAuthentication.make()])
401
415
  .mailer(fileMailer(process.env.MAIL_LOG ?? 'storage/mail.log'))
402
416
  ${teams ? ` // Every page lives under /app/<team>; records with a \`team_id\` column only show to that team's members.
403
417
  .tenant(knexTenantProvider((table) => db(table), { label: 'Current team' }), { ownershipColumn: 'team_id' })
@@ -475,7 +489,8 @@ export class NoteResource extends Resource {
475
489
  files['src/server.ts'] = `// Entry point: runs the migrations, then serves every panel of src/panels.
476
490
  import express from 'express';
477
491
  import { mkdirSync } from 'node:fs';
478
- import { discoverPanels, panelRouter } from '@navette-js/panels';
492
+ import { discoverPanels, panelNotFound, panelRouter } from '@navette-js/panels';
493
+ import { views } from '@navette-js/support';
479
494
  ${o.locale === 'fr' ? "import { setLocale } from '@navette-js/support/i18n';\n" : ''}import { UploadStore, WireRuntime } from '@navette-js/wire';
480
495
  import { migrate } from './database.ts';
481
496
 
@@ -484,15 +499,20 @@ if (!key) throw new Error('APP_KEY is missing: start the server with \`npm run d
484
499
  ${o.locale === 'fr' ? "setLocale('fr');\n" : ''}mkdirSync('storage/public', { recursive: true });
485
500
 
486
501
  for (const name of await migrate()) console.log(\`Migrated \${name}\`);
502
+ // View components: <x-name> in any view renders src/components/name.ejs (\`npx navette make:component name\`).
503
+ views.components(new URL('./components/', import.meta.url));
487
504
 
488
505
  const app = express();
489
506
  // Uploaded files that are public (FileUpload with the public disk).
490
507
  app.use('/storage', express.static('storage/public', { dotfiles: 'deny', index: false }));
491
508
  // Each panel has its own runtime (its own Livewire endpoint and components).
492
- for (const panel of await discoverPanels(new URL('./panels/', import.meta.url))) {
509
+ const panels = await discoverPanels(new URL('./panels/', import.meta.url));
510
+ for (const panel of panels) {
493
511
  app.use(panelRouter(panel, new WireRuntime({ key, uploads: new UploadStore('storage/tmp', key) })));
494
512
  }
495
513
  app.get('/', (_req, res) => res.redirect('/${o.userPanel ? 'app' : 'admin'}'));
514
+ // Any other URL: the 404 page of the first panel.
515
+ if (panels[0]) app.use(panelNotFound(panels[0]));
496
516
 
497
517
  const port = Number(process.env.PORT ?? ${o.port});
498
518
  app.listen(port, '127.0.0.1', () => {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "create-navette",
3
- "version": "1.0.0-beta.4",
3
+ "version": "1.0.0-beta.5",
4
4
  "description": "Create a Navette project: npm create navette@latest",
5
5
  "license": "MIT",
6
6
  "type": "module",