create-navette 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/lib/index.js +40 -3
- package/lib/secret.d.ts +5 -0
- package/lib/secret.js +40 -0
- package/lib/templates.js +39 -18
- 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,12 +222,10 @@ ${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,
|
|
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
|
-
|
|
221
|
-
const users = knexAuthProvider((table) => db(table));
|
|
222
|
-
const isAdmin = async (id: string | number) => (await db('users').where('id', id).first())?.role === 'admin';
|
|
228
|
+
import { users } from '../users.ts';
|
|
223
229
|
|
|
224
230
|
export default Panel.make()
|
|
225
231
|
.id('admin')
|
|
@@ -230,21 +236,26 @@ export default Panel.make()
|
|
|
230
236
|
.setColors({ primary: Color.Amber })
|
|
231
237
|
.login()
|
|
232
238
|
.passwordReset()
|
|
233
|
-
|
|
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()])
|
|
234
243
|
.mailer(fileMailer(process.env.MAIL_LOG ?? 'storage/mail.log'))
|
|
235
244
|
${sqlite ? ' .databaseNotifications(knexNotificationStore((table: string) => db(table)))\n' : ''} .globalSearchKeyBindings(['command+k', 'ctrl+k'])
|
|
236
245
|
// Resources, pages and widgets are discovered from these folders: \`npx navette make:resource\` adds files there.
|
|
237
246
|
.resources(await discoverResources(new URL('../admin/resources/', import.meta.url)))
|
|
238
247
|
.pages(await discoverPages(new URL('../admin/pages/', import.meta.url)))
|
|
239
248
|
.widgets([AccountWidget, ...(await discoverWidgets(new URL('../admin/widgets/', import.meta.url)))])
|
|
240
|
-
.auth(
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
249
|
+
.auth(users)
|
|
250
|
+
// Only users with the \`admin\` role may sign in here; others are refused at login and get a 403 page.
|
|
251
|
+
.canAccess(async (user) => (await db('users').where('id', user.id).first())?.role === 'admin');
|
|
252
|
+
`;
|
|
253
|
+
files['src/users.ts'] = `// Users of every panel. Panels share one session (signing into one signs into the others), so they share this provider;
|
|
254
|
+
// each panel's \`canAccess()\` decides who may enter it.
|
|
255
|
+
import { knexAuthProvider } from '@navette-js/panels';
|
|
256
|
+
import { db } from './database.ts';
|
|
257
|
+
|
|
258
|
+
export const users = knexAuthProvider((table) => db(table));
|
|
248
259
|
`;
|
|
249
260
|
files['src/admin/pages/Dashboard.ts'] = `import { Dashboard as BaseDashboard } from '@navette-js/panels';
|
|
250
261
|
|
|
@@ -383,8 +394,9 @@ 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,
|
|
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';
|
|
399
|
+
import { users } from '../users.ts';
|
|
388
400
|
${teams ? "import { EditTeamProfile, RegisterTeam } from '../app/tenancy.ts';\n" : ''}
|
|
389
401
|
export default Panel.make()
|
|
390
402
|
.id('app')
|
|
@@ -396,7 +408,10 @@ export default Panel.make()
|
|
|
396
408
|
.registration()
|
|
397
409
|
.passwordReset()
|
|
398
410
|
.emailVerification()
|
|
399
|
-
|
|
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()])
|
|
400
415
|
.mailer(fileMailer(process.env.MAIL_LOG ?? 'storage/mail.log'))
|
|
401
416
|
${teams ? ` // Every page lives under /app/<team>; records with a \`team_id\` column only show to that team's members.
|
|
402
417
|
.tenant(knexTenantProvider((table) => db(table), { label: 'Current team' }), { ownershipColumn: 'team_id' })
|
|
@@ -405,7 +420,7 @@ ${teams ? ` // Every page lives under /app/<team>; records with a \`team_id\`
|
|
|
405
420
|
` : ''} .resources(await discoverResources(new URL('../app/resources/', import.meta.url)))
|
|
406
421
|
.pages(await discoverPages(new URL('../app/pages/', import.meta.url)))
|
|
407
422
|
.widgets([AccountWidget, ...(await discoverWidgets(new URL('../app/widgets/', import.meta.url)))])
|
|
408
|
-
.auth(
|
|
423
|
+
.auth(users);
|
|
409
424
|
`;
|
|
410
425
|
files['src/app/pages/Dashboard.ts'] = `import { Dashboard as BaseDashboard } from '@navette-js/panels';
|
|
411
426
|
|
|
@@ -474,7 +489,8 @@ export class NoteResource extends Resource {
|
|
|
474
489
|
files['src/server.ts'] = `// Entry point: runs the migrations, then serves every panel of src/panels.
|
|
475
490
|
import express from 'express';
|
|
476
491
|
import { mkdirSync } from 'node:fs';
|
|
477
|
-
import { discoverPanels, panelRouter } from '@navette-js/panels';
|
|
492
|
+
import { discoverPanels, panelNotFound, panelRouter } from '@navette-js/panels';
|
|
493
|
+
import { views } from '@navette-js/support';
|
|
478
494
|
${o.locale === 'fr' ? "import { setLocale } from '@navette-js/support/i18n';\n" : ''}import { UploadStore, WireRuntime } from '@navette-js/wire';
|
|
479
495
|
import { migrate } from './database.ts';
|
|
480
496
|
|
|
@@ -483,15 +499,20 @@ if (!key) throw new Error('APP_KEY is missing: start the server with \`npm run d
|
|
|
483
499
|
${o.locale === 'fr' ? "setLocale('fr');\n" : ''}mkdirSync('storage/public', { recursive: true });
|
|
484
500
|
|
|
485
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));
|
|
486
504
|
|
|
487
505
|
const app = express();
|
|
488
506
|
// Uploaded files that are public (FileUpload with the public disk).
|
|
489
507
|
app.use('/storage', express.static('storage/public', { dotfiles: 'deny', index: false }));
|
|
490
508
|
// Each panel has its own runtime (its own Livewire endpoint and components).
|
|
491
|
-
|
|
509
|
+
const panels = await discoverPanels(new URL('./panels/', import.meta.url));
|
|
510
|
+
for (const panel of panels) {
|
|
492
511
|
app.use(panelRouter(panel, new WireRuntime({ key, uploads: new UploadStore('storage/tmp', key) })));
|
|
493
512
|
}
|
|
494
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]));
|
|
495
516
|
|
|
496
517
|
const port = Number(process.env.PORT ?? ${o.port});
|
|
497
518
|
app.listen(port, '127.0.0.1', () => {
|