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 +40 -3
- package/lib/secret.d.ts +5 -0
- package/lib/secret.js +40 -0
- package/lib/templates.js +26 -6
- package/package.json +1 -1
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 =
|
|
106
|
-
|
|
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
|
|
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`);
|
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/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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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', () => {
|