create-navette 0.0.0-stage → 1.0.0-beta.2

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/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 NavetteJS
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md CHANGED
@@ -1,3 +1,9 @@
1
- # Temporary Holding Version
1
+ # create-navette
2
2
 
3
- This version is a temporary placeholder for this package. An operational version to replace this has been submitted for review and is awaiting a staged release.
3
+ Create a Navette project: npm create navette@latest
4
+
5
+ Part of [Navette](https://github.com/NavetteJS/navette), admin panels for Node.js. Start a project with `npm create navette@latest`.
6
+
7
+ **Beta:** the API may still change between beta releases.
8
+
9
+ License: MIT. Third-party notices: see `THIRD-PARTY-NOTICES.md` where present.
package/lib/index.d.ts ADDED
@@ -0,0 +1,2 @@
1
+ #!/usr/bin/env node
2
+ export {};
package/lib/index.js ADDED
@@ -0,0 +1,122 @@
1
+ #!/usr/bin/env node
2
+ // `npm create navette`: asks a few questions, then creates a ready-to-run Navette project (panels, migrations, admin user).
3
+ // Without an interactive terminal (or with --yes), answers come from the flags and defaults.
4
+ import { spawnSync } from 'node:child_process';
5
+ import { randomBytes } from 'node:crypto';
6
+ import { existsSync, mkdirSync, readdirSync, readFileSync, writeFileSync } from 'node:fs';
7
+ import { dirname, join, resolve } from 'node:path';
8
+ import { createInterface } from 'node:readline/promises';
9
+ import { fileURLToPath } from 'node:url';
10
+ import { projectFiles } from "./templates.js";
11
+ const own = JSON.parse(readFileSync(join(dirname(fileURLToPath(import.meta.url)), '..', 'package.json'), 'utf8'));
12
+ const args = process.argv.slice(2);
13
+ const flags = {};
14
+ const positional = [];
15
+ for (const arg of args) {
16
+ const m = /^--([a-z-]+)(?:=(.*))?$/.exec(arg);
17
+ if (m)
18
+ flags[m[1]] = m[2] ?? true;
19
+ else
20
+ positional.push(arg);
21
+ }
22
+ const flag = (name) => (typeof flags[name] === 'string' ? flags[name] : undefined);
23
+ const interactive = process.stdin.isTTY && !flags.yes;
24
+ const say = (text = '') => process.stdout.write(text + '\n');
25
+ const bold = (text) => (process.stdout.isTTY ? `\x1b[1m${text}\x1b[0m` : text);
26
+ const dim = (text) => (process.stdout.isTTY ? `\x1b[2m${text}\x1b[0m` : text);
27
+ const fail = (text) => { process.stderr.write(`\n ✖ ${text}\n\n`); process.exit(1); };
28
+ let rl = null;
29
+ const prompt = () => (rl ??= createInterface({ input: process.stdin, output: process.stdout }));
30
+ async function text(question, fallback, given) {
31
+ if (given !== undefined)
32
+ return given;
33
+ if (!interactive)
34
+ return fallback;
35
+ return (await prompt().question(` ${bold(question)} ${dim(`(${fallback})`)} `)).trim() || fallback;
36
+ }
37
+ async function choose(question, choices, fallback, given) {
38
+ if (given !== undefined) {
39
+ const match = choices.find(([value]) => value === given);
40
+ if (!match)
41
+ fail(`--${question.toLowerCase()} must be one of ${choices.map(([v]) => v).join(', ')}`);
42
+ return match[0];
43
+ }
44
+ if (!interactive)
45
+ return fallback;
46
+ say(` ${bold(question)}`);
47
+ choices.forEach(([value, label], index) => say(` ${index + 1}. ${label}${value === fallback ? dim(' (default)') : ''}`));
48
+ const answer = (await prompt().question(` ${dim('›')} `)).trim();
49
+ return choices[Number(answer) - 1]?.[0] ?? choices.find(([value]) => value === answer)?.[0] ?? fallback;
50
+ }
51
+ async function confirm(question, fallback, given) {
52
+ if (given !== undefined)
53
+ return given === true || given === 'true' || given === 'yes';
54
+ if (!interactive)
55
+ return fallback;
56
+ const answer = (await prompt().question(` ${bold(question)} ${dim(fallback ? '(Y/n)' : '(y/N)')} `)).trim().toLowerCase();
57
+ return answer ? answer.startsWith('y') || answer.startsWith('o') : fallback;
58
+ }
59
+ say(`\n ${bold('Navette')} ${dim(`v${own.version}`)} — admin panels for Node.js\n`);
60
+ const name = (positional[0] ?? (await text('Project name', 'my-navette-app', flag('name')))).trim();
61
+ if (!/^[a-z0-9][a-z0-9._-]*$/i.test(name))
62
+ fail(`Invalid project name: ${name} (letters, digits, dots, dashes and underscores).`);
63
+ const target = resolve(name);
64
+ if (existsSync(target) && readdirSync(target).length)
65
+ fail(`The folder ${name} already exists and is not empty.`);
66
+ const template = await choose('Template', [['starter', 'Starter — a dashboard with stats, a tasks resource and user management'], ['blank', 'Blank — an empty admin panel, ready for your resources']], 'starter', flag('template'));
67
+ const userPanel = await confirm('Add a user panel (/app) with sign-up, password reset, email verification and profile?', false, flags['user-panel']);
68
+ const teams = userPanel ? await confirm('Let users create and join teams (multi-tenancy)?', false, flags.teams) : false;
69
+ const database = await choose('Database', [['sqlite', 'SQLite — a file, nothing to install'], ['postgres', 'PostgreSQL'], ['mysql', 'MySQL / MariaDB']], 'sqlite', flag('db'));
70
+ const locale = await choose('Language of the interface', [['en', 'English'], ['fr', 'Français']], 'en', flag('locale'));
71
+ const install = await confirm('Install the dependencies now?', true, flags['no-install'] ? 'no' : flags.install);
72
+ const adminEmail = install && database === 'sqlite' ? await text('Admin email address', 'admin@example.com', flag('admin-email')) : null;
73
+ rl?.close();
74
+ const options = {
75
+ name: name.toLowerCase(), appName: name.replace(/[-_.]+/g, ' ').replace(/\b\w/g, (c) => c.toUpperCase()),
76
+ template, userPanel, teams, database, locale, version: flag('version') ?? `^${own.version}`, port: Number(flag('port') ?? 3000),
77
+ };
78
+ const files = projectFiles(options);
79
+ // For tests: install packages from local tarballs instead of the npm registry (--packages=<folder of .tgz files>).
80
+ const local = flag('packages');
81
+ if (local) {
82
+ const pkg = JSON.parse(files['package.json']);
83
+ for (const group of ['dependencies', 'devDependencies'])
84
+ for (const dependency of Object.keys(pkg[group])) {
85
+ if (!dependency.startsWith('@navette-js/'))
86
+ continue;
87
+ const archive = readdirSync(local).find((file) => file.startsWith(`navette-js-${dependency.slice(12)}-`) && file.endsWith('.tgz'));
88
+ if (archive)
89
+ pkg[group][dependency] = `file:${resolve(local, archive).replaceAll('\\', '/')}`;
90
+ }
91
+ files['package.json'] = JSON.stringify(pkg, null, 2) + '\n';
92
+ }
93
+ for (const [file, content] of Object.entries(files)) {
94
+ mkdirSync(dirname(join(target, file)), { recursive: true });
95
+ writeFileSync(join(target, file), content);
96
+ }
97
+ say(`\n ✔ Created ${name} (${Object.keys(files).length} files)`);
98
+ const run = (command, commandArgs) => spawnSync(command, commandArgs, { cwd: target, stdio: 'inherit', shell: process.platform === 'win32' }).status === 0;
99
+ let adminPassword = null;
100
+ if (install) {
101
+ say(' … Installing dependencies\n');
102
+ if (!run('npm', ['install', '--no-fund', '--no-audit']))
103
+ fail('npm install failed: fix the error above, then run `npm install` in the project.');
104
+ 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}`]))
107
+ fail('Creating the admin user failed: run `npx navette make:user --admin` in the project.');
108
+ }
109
+ }
110
+ say(`\n ${bold('Next steps')}\n`);
111
+ say(` cd ${name}`);
112
+ if (!install)
113
+ say(' npm install');
114
+ if (database !== 'sqlite')
115
+ say(` ${dim('# set DATABASE_URL in .env, then:')}`);
116
+ if (!adminPassword)
117
+ say(' npx navette make:user --admin');
118
+ say(' npm run dev\n');
119
+ say(` Admin panel http://127.0.0.1:${options.port}/admin${adminPassword ? ` ${dim('sign in:')} ${adminEmail} / ${adminPassword}` : ''}`);
120
+ if (userPanel)
121
+ say(` User panel http://127.0.0.1:${options.port}/app`);
122
+ say(`\n Generate code with ${bold('npx navette make:resource Post --generate')} — see README.md.\n`);
@@ -0,0 +1,17 @@
1
+ export type ProjectOptions = {
2
+ /** Folder and package name. */
3
+ name: string;
4
+ /** Name shown in the panel. */
5
+ appName: string;
6
+ template: 'blank' | 'starter';
7
+ /** Second panel (`/app`) for users: sign-up, password reset, email verification, profile. */
8
+ userPanel: boolean;
9
+ /** Teams (multi-tenancy) in the `/app` panel. */
10
+ teams: boolean;
11
+ database: 'sqlite' | 'postgres' | 'mysql';
12
+ locale: 'en' | 'fr';
13
+ /** Version range of the @navette-js packages to install. */
14
+ version: string;
15
+ port: number;
16
+ };
17
+ export declare function projectFiles(o: ProjectOptions): Record<string, string>;
@@ -0,0 +1,546 @@
1
+ // Files of a new Navette project, built from the `npm create navette` answers.
2
+ // The generated code doubles as an example: each file has a single, clear role.
3
+ import { randomBytes } from 'node:crypto';
4
+ const json = (value) => JSON.stringify(value, null, 2) + '\n';
5
+ export function projectFiles(o) {
6
+ const files = {};
7
+ const starter = o.template === 'starter';
8
+ const teams = o.userPanel && o.teams;
9
+ const sqlite = o.database === 'sqlite';
10
+ const navette = (name) => [`@navette-js/${name}`, o.version];
11
+ // ------------------------------------------------------------------ root
12
+ files['package.json'] = json({
13
+ name: o.name, private: true, type: 'module',
14
+ scripts: { dev: 'node --env-file=.env --watch src/server.ts', start: 'node --env-file=.env src/server.ts', navette: 'navette' },
15
+ engines: { node: '>=24' },
16
+ dependencies: Object.fromEntries([
17
+ ...['actions', 'database', 'forms', 'infolists', 'panels', 'schemas', 'support', 'tables', 'wire'].map(navette),
18
+ ['express', '^5.1.0'],
19
+ ...(o.database === 'postgres' ? [['pg', '^8.13.0']] : o.database === 'mysql' ? [['mysql2', '^3.11.0']] : []),
20
+ ]),
21
+ devDependencies: Object.fromEntries([navette('cli'), ['@types/express', '^5.0.0'], ['@types/node', '^24.0.0'], ['typescript', '^5.9.0']]),
22
+ });
23
+ files['tsconfig.json'] = json({
24
+ // For your editor and `npx tsc`: Node runs the TypeScript sources directly, nothing is compiled.
25
+ compilerOptions: { target: 'es2023', module: 'nodenext', moduleResolution: 'nodenext', allowImportingTsExtensions: true, noEmit: true, strict: false, skipLibCheck: true, types: ['node'] },
26
+ include: ['src'],
27
+ });
28
+ const env = (key) => [
29
+ `APP_NAME="${o.appName.replace(/"/g, '')}"`,
30
+ `APP_KEY=${key}`,
31
+ `APP_URL=http://127.0.0.1:${o.port}`,
32
+ `PORT=${o.port}`,
33
+ '',
34
+ sqlite ? 'DB_CONNECTION=sqlite' : `DB_CONNECTION=${o.database === 'postgres' ? 'pgsql' : 'mysql'}`,
35
+ sqlite ? 'DB_DATABASE=database/database.sqlite' : `DATABASE_URL=${o.database === 'postgres' ? 'postgres://user:password@127.0.0.1:5432/' : 'mysql://user:password@127.0.0.1:3306/'}${o.name.replace(/[^a-z0-9_]/gi, '_')}`,
36
+ '',
37
+ '# E-mails (password reset, email verification) are written to this file in development.',
38
+ 'MAIL_LOG=storage/mail.log',
39
+ '',
40
+ ].join('\n');
41
+ files['.env'] = env(randomBytes(32).toString('base64url'));
42
+ files['.env.example'] = env('');
43
+ files['.gitignore'] = ['node_modules/', '.env', 'database/*.sqlite*', 'storage/', ''].join('\n');
44
+ // ------------------------------------------------------------------ database
45
+ files['src/database.ts'] = `// Database connection and migrations.
46
+ import { mkdirSync, readdirSync } from 'node:fs';
47
+ import { dirname } from 'node:path';
48
+ import { fileURLToPath, pathToFileURL } from 'node:url';
49
+ import { createConnection, sqlite, type Knex } from '@navette-js/database';
50
+
51
+ function connect(): Knex {
52
+ const connection = process.env.DB_CONNECTION ?? 'sqlite';
53
+ if (connection === 'sqlite') {
54
+ const file = process.env.DB_DATABASE ?? 'database/database.sqlite';
55
+ mkdirSync(dirname(file), { recursive: true });
56
+ return sqlite(file);
57
+ }
58
+ return createConnection({ client: connection === 'mysql' ? 'mysql2' : 'pg', connection: process.env.DATABASE_URL });
59
+ }
60
+
61
+ export const db = connect();
62
+
63
+ /** Runs the migrations of \`src/migrations\` that have not run yet, in file name order. Called when the server starts. */
64
+ export async function migrate(): Promise<string[]> {
65
+ if (!(await db.schema.hasTable('migrations'))) {
66
+ await db.schema.createTable('migrations', (table) => {
67
+ table.increments('id');
68
+ table.string('name').unique();
69
+ table.integer('batch');
70
+ table.timestamp('ran_at');
71
+ });
72
+ }
73
+ const done = new Set((await db('migrations').select('name')).map((row: { name: string }) => row.name));
74
+ const directory = fileURLToPath(new URL('./migrations/', import.meta.url));
75
+ const batch = Number((await db('migrations').max({ batch: 'batch' }).first())?.batch ?? 0) + 1;
76
+ const ran: string[] = [];
77
+ for (const file of readdirSync(directory).filter((name) => /\\.(m?ts|m?js)$/.test(name)).sort()) {
78
+ const name = file.replace(/\\.(m?ts|m?js)$/, '');
79
+ if (done.has(name)) continue;
80
+ const migration = await import(pathToFileURL(directory + file).href);
81
+ await db.transaction(async (trx) => {
82
+ await migration.up(trx);
83
+ await trx('migrations').insert({ name, batch, ran_at: new Date() });
84
+ });
85
+ ran.push(name);
86
+ }
87
+ return ran;
88
+ }
89
+ `;
90
+ files['src/migrations/0001_create_users_table.ts'] = `import type { Knex } from '@navette-js/database';
91
+
92
+ export async function up(db: Knex): Promise<void> {
93
+ await db.schema.createTable('users', (table) => {
94
+ table.increments('id');
95
+ table.string('name');
96
+ table.string('email').unique();
97
+ table.string('password');
98
+ // \`admin\` users can sign in to the admin panel.
99
+ table.string('role').defaultTo('user');
100
+ table.timestamp('email_verified_at').nullable();
101
+ table.timestamps();
102
+ });
103
+ }
104
+ `;
105
+ files['src/migrations/0002_create_notifications_table.ts'] = `import type { Knex } from '@navette-js/database';
106
+
107
+ // Database notifications (the bell).
108
+ export async function up(db: Knex): Promise<void> {
109
+ await db.schema.createTable('notifications', (table) => {
110
+ table.string('id', 36).primary();
111
+ table.string('type');
112
+ table.string('notifiable_type');
113
+ table.integer('notifiable_id');
114
+ table.text('data');
115
+ table.timestamp('read_at').nullable();
116
+ table.timestamps();
117
+ table.index(['notifiable_type', 'notifiable_id']);
118
+ });
119
+ }
120
+ `;
121
+ if (teams)
122
+ files['src/migrations/0003_create_teams_tables.ts'] = `import type { Knex } from '@navette-js/database';
123
+
124
+ export async function up(db: Knex): Promise<void> {
125
+ await db.schema.createTable('teams', (table) => {
126
+ table.increments('id');
127
+ table.string('name');
128
+ table.timestamps();
129
+ });
130
+ // Team members.
131
+ await db.schema.createTable('team_user', (table) => {
132
+ table.increments('id');
133
+ table.integer('team_id').references('id').inTable('teams').onDelete('cascade');
134
+ table.integer('user_id').references('id').inTable('users').onDelete('cascade');
135
+ table.timestamps();
136
+ });
137
+ }
138
+ `;
139
+ if (starter)
140
+ files['src/migrations/0004_create_tasks_table.ts'] = `import type { Knex } from '@navette-js/database';
141
+
142
+ export async function up(db: Knex): Promise<void> {
143
+ await db.schema.createTable('tasks', (table) => {
144
+ table.increments('id');
145
+ table.string('title');
146
+ table.text('notes').nullable();
147
+ table.string('priority').defaultTo('normal');
148
+ table.date('due_date').nullable();
149
+ table.boolean('is_done').defaultTo(false);
150
+ table.timestamps();
151
+ });
152
+ }
153
+ `;
154
+ if (starter && teams)
155
+ files['src/migrations/0005_create_notes_table.ts'] = `import type { Knex } from '@navette-js/database';
156
+
157
+ // Notes belong to a team: the \`team_id\` column scopes them to the current team in the /app panel.
158
+ export async function up(db: Knex): Promise<void> {
159
+ await db.schema.createTable('notes', (table) => {
160
+ table.increments('id');
161
+ table.integer('team_id').references('id').inTable('teams').onDelete('cascade');
162
+ table.string('title');
163
+ table.text('body').nullable();
164
+ table.timestamps();
165
+ });
166
+ }
167
+ `;
168
+ // ------------------------------------------------------------------ models
169
+ const timestamps = ` $beforeInsert(): void { (this as any).created_at = (this as any).updated_at = now(); }
170
+ $beforeUpdate(): void { (this as any).updated_at = now(); }`;
171
+ const nowHelper = `const now = () => new Date().toISOString().slice(0, 19).replace('T', ' ');`;
172
+ files['src/models/User.ts'] = `import { Model } from '@navette-js/database';
173
+
174
+ ${nowHelper}
175
+
176
+ export class User extends Model {
177
+ static tableName = 'users';
178
+ declare id: number;
179
+ declare name: string;
180
+ declare email: string;
181
+ declare role: string;
182
+ ${timestamps}
183
+ }
184
+ `;
185
+ if (starter)
186
+ files['src/models/Task.ts'] = `import { Model } from '@navette-js/database';
187
+
188
+ ${nowHelper}
189
+
190
+ export class Task extends Model {
191
+ static tableName = 'tasks';
192
+ declare id: number;
193
+ declare title: string;
194
+ declare notes: string | null;
195
+ declare priority: string;
196
+ declare due_date: string | null;
197
+ declare is_done: boolean | number;
198
+ ${timestamps}
199
+ }
200
+ `;
201
+ if (starter && teams)
202
+ files['src/models/Note.ts'] = `import { Model } from '@navette-js/database';
203
+
204
+ ${nowHelper}
205
+
206
+ export class Note extends Model {
207
+ static tableName = 'notes';
208
+ declare id: number;
209
+ declare team_id: number;
210
+ declare title: string;
211
+ declare body: string | null;
212
+ ${timestamps}
213
+ }
214
+ `;
215
+ // ------------------------------------------------------------------ admin panel
216
+ 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, knexAuthProvider, ${sqlite ? 'knexNotificationStore, ' : ''}Panel } from '@navette-js/panels';
218
+ import { Color } from '@navette-js/support';
219
+ 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';
223
+
224
+ export default Panel.make()
225
+ .id('admin')
226
+ .path('admin')
227
+ .key(process.env.APP_KEY!)
228
+ .appUrl(process.env.APP_URL ?? null)
229
+ .brandName(process.env.APP_NAME ?? 'Navette')
230
+ .setColors({ primary: Color.Amber })
231
+ .login()
232
+ .passwordReset()
233
+ .profile()
234
+ .mailer(fileMailer(process.env.MAIL_LOG ?? 'storage/mail.log'))
235
+ ${sqlite ? ' .databaseNotifications(knexNotificationStore((table: string) => db(table)))\n' : ''} .globalSearchKeyBindings(['command+k', 'ctrl+k'])
236
+ // Resources, pages and widgets are discovered from these folders: \`npx navette make:resource\` adds files there.
237
+ .resources(await discoverResources(new URL('../admin/resources/', import.meta.url)))
238
+ .pages(await discoverPages(new URL('../admin/pages/', import.meta.url)))
239
+ .widgets([AccountWidget, ...(await discoverWidgets(new URL('../admin/widgets/', import.meta.url)))])
240
+ .auth({
241
+ ...users,
242
+ attempt: async (email, password) => {
243
+ const user = await users.attempt(email, password);
244
+ return user && (await isAdmin(user.id)) ? user : null;
245
+ },
246
+ find: async (id) => ((await isAdmin(id)) ? users.find(id) : null),
247
+ });
248
+ `;
249
+ files['src/admin/pages/Dashboard.ts'] = `import { Dashboard as BaseDashboard } from '@navette-js/panels';
250
+
251
+ // The panel's home page: it shows the panel's widgets (src/admin/widgets).
252
+ export class Dashboard extends BaseDashboard {}
253
+ `;
254
+ files['src/admin/resources/UserResource.ts'] = `import { ObjectionAdapter } from '@navette-js/database';
255
+ import { Select, TextInput } from '@navette-js/forms';
256
+ import { Resource } from '@navette-js/panels';
257
+ import type { Schema } from '@navette-js/schemas';
258
+ import { TextColumn, SelectFilter, type Table } from '@navette-js/tables';
259
+ import { User } from '../../models/User.ts';
260
+
261
+ const ROLES: [string, string][] = [['user', 'User'], ['admin', 'Admin']];
262
+
263
+ // Users sign up${o.userPanel ? ' on /app/register' : ''} or are created with \`npx navette make:user\`; here you edit their name and role.
264
+ export class UserResource extends Resource {
265
+ static slug = 'users';
266
+ static modelLabel = 'user';
267
+ static navigationIcon = 'o-users';
268
+ static navigationSort = 10;
269
+ static recordTitleAttribute = 'name';
270
+ static globallySearchableAttributes = ['name', 'email'];
271
+ static adapter = new ObjectionAdapter(User);
272
+ static policy = { create: () => false };
273
+
274
+ static form(schema: Schema): Schema {
275
+ return schema.components([
276
+ TextInput.make('name').required().maxLength(255),
277
+ TextInput.make('email').email().required().maxLength(255).unique({ ignoreRecord: true }),
278
+ Select.make('role').options(ROLES).required(),
279
+ ]);
280
+ }
281
+
282
+ static table(table: Table<User>): Table<User> {
283
+ return table
284
+ .columns([
285
+ TextColumn.make<User>('name').searchable().sortable(),
286
+ TextColumn.make<User>('email').searchable(),
287
+ TextColumn.make<User>('role').badge().color((user) => (user.role === 'admin' ? 'warning' : 'gray')),
288
+ TextColumn.make<User>('created_at').dateTime().sortable(),
289
+ ])
290
+ .filters([SelectFilter.make('role').options(ROLES)]);
291
+ }
292
+ }
293
+ `;
294
+ if (starter) {
295
+ files['src/admin/resources/TaskResource.ts'] = `import { Action } from '@navette-js/actions';
296
+ import { ObjectionAdapter } from '@navette-js/database';
297
+ import { DatePicker, Select, Textarea, TextInput, Toggle } from '@navette-js/forms';
298
+ import { DeleteBulkAction, Notification, Resource, Tab } from '@navette-js/panels';
299
+ import { Section, type Schema } from '@navette-js/schemas';
300
+ import { IconColumn, SelectFilter, TextColumn, type Table } from '@navette-js/tables';
301
+ import { Task } from '../../models/Task.ts';
302
+
303
+ const PRIORITIES: Record<string, { label: string; color: string }> = {
304
+ low: { label: 'Low', color: 'gray' },
305
+ normal: { label: 'Normal', color: 'info' },
306
+ high: { label: 'High', color: 'danger' },
307
+ };
308
+ const priorityOptions = Object.entries(PRIORITIES).map(([value, priority]): [string, string] => [value, priority.label]);
309
+
310
+ export class TaskResource extends Resource {
311
+ static slug = 'tasks';
312
+ static modelLabel = 'task';
313
+ static navigationIcon = 'o-clipboard-document-check';
314
+ static recordTitleAttribute = 'title';
315
+ static adapter = new ObjectionAdapter(Task);
316
+
317
+ // Tabs above the table, each with its own query.
318
+ static tabs(): Record<string, Tab> {
319
+ return {
320
+ '': Tab.make('All'),
321
+ open: Tab.make('To do').query((query) => { query.where('is_done', false); }),
322
+ done: Tab.make('Done').query((query) => { query.where('is_done', true); }),
323
+ };
324
+ }
325
+
326
+ static form(schema: Schema): Schema {
327
+ return schema.components([
328
+ Section.make('Task')
329
+ .schema([
330
+ TextInput.make('title').required().maxLength(255),
331
+ Select.make('priority').options(priorityOptions).default('normal').required(),
332
+ DatePicker.make('due_date'),
333
+ Toggle.make('is_done').label('Done'),
334
+ Textarea.make('notes').rows(4).columnSpanFull(),
335
+ ])
336
+ .columns(2)
337
+ .columnSpanFull(),
338
+ ]);
339
+ }
340
+
341
+ static table(table: Table<Task>): Table<Task> {
342
+ return table
343
+ .columns([
344
+ TextColumn.make<Task>('title').searchable().sortable().weight('medium'),
345
+ TextColumn.make<Task>('priority').badge()
346
+ .formatStateUsing(({ record }) => PRIORITIES[record.priority]?.label ?? record.priority)
347
+ .color((record) => PRIORITIES[record.priority]?.color ?? 'gray'),
348
+ TextColumn.make<Task>('due_date').date().sortable().placeholder('—'),
349
+ IconColumn.make<Task>('is_done').label('Done').boolean().sortable(),
350
+ ])
351
+ .defaultSort('due_date')
352
+ .filters([SelectFilter.make('priority').options(priorityOptions)])
353
+ .recordActions([
354
+ // A custom row action: it runs on the server when clicked.
355
+ Action.make('toggle')
356
+ .label(({ record }) => ((record as Task).is_done ? 'Reopen' : 'Complete'))
357
+ .icon(({ record }) => ((record as Task).is_done ? 'c-arrow-uturn-left' : 'c-check'))
358
+ .action(async ({ record }) => {
359
+ await TaskResource.adapter.update((record as Task).id, { is_done: !(record as Task).is_done });
360
+ Notification.make().success().title('Task updated').send();
361
+ }),
362
+ ])
363
+ .groupedBulkActions([DeleteBulkAction.make()]);
364
+ }
365
+ }
366
+ `;
367
+ files['src/admin/widgets/TaskStats.ts'] = `import { Stat, StatsOverviewWidget } from '@navette-js/panels';
368
+ import { Task } from '../../models/Task.ts';
369
+ import { User } from '../../models/User.ts';
370
+
371
+ // Shown on the dashboard. Widgets are discovered from src/admin/widgets.
372
+ export class TaskStats extends StatsOverviewWidget {
373
+ async getStats(): Promise<Stat[]> {
374
+ return [
375
+ Stat.make('Open tasks', await Task.query().where('is_done', false).resultSize()).color('warning'),
376
+ Stat.make('Done', await Task.query().where('is_done', true).resultSize()).color('success'),
377
+ Stat.make('Users', await User.query().resultSize()),
378
+ ];
379
+ }
380
+ }
381
+ `;
382
+ }
383
+ // ------------------------------------------------------------------ user panel
384
+ if (o.userPanel) {
385
+ 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, knexAuthProvider, ${teams ? 'knexTenantProvider, ' : ''}Panel } from '@navette-js/panels';
387
+ import { db } from '../database.ts';
388
+ ${teams ? "import { EditTeamProfile, RegisterTeam } from '../app/tenancy.ts';\n" : ''}
389
+ export default Panel.make()
390
+ .id('app')
391
+ .path('app')
392
+ .key(process.env.APP_KEY!)
393
+ .appUrl(process.env.APP_URL ?? null)
394
+ .brandName(process.env.APP_NAME ?? 'Navette')
395
+ .login()
396
+ .registration()
397
+ .passwordReset()
398
+ .emailVerification()
399
+ .profile()
400
+ .mailer(fileMailer(process.env.MAIL_LOG ?? 'storage/mail.log'))
401
+ ${teams ? ` // Every page lives under /app/<team>; records with a \`team_id\` column only show to that team's members.
402
+ .tenant(knexTenantProvider((table) => db(table), { label: 'Current team' }), { ownershipColumn: 'team_id' })
403
+ .tenantRegistration(RegisterTeam)
404
+ .tenantProfile(EditTeamProfile)
405
+ ` : ''} .resources(await discoverResources(new URL('../app/resources/', import.meta.url)))
406
+ .pages(await discoverPages(new URL('../app/pages/', import.meta.url)))
407
+ .widgets([AccountWidget, ...(await discoverWidgets(new URL('../app/widgets/', import.meta.url)))])
408
+ .auth(knexAuthProvider((table) => db(table)));
409
+ `;
410
+ files['src/app/pages/Dashboard.ts'] = `import { Dashboard as BaseDashboard } from '@navette-js/panels';
411
+
412
+ // The panel's home page: it shows the panel's widgets (src/app/widgets).
413
+ export class Dashboard extends BaseDashboard {}
414
+ `;
415
+ files['src/app/widgets/.gitkeep'] = '';
416
+ if (teams) {
417
+ files['src/app/tenancy.ts'] = `import { EditTenantProfile, RegisterTenant } from '@navette-js/panels';
418
+ import { TextInput } from '@navette-js/forms';
419
+ import type { Schema } from '@navette-js/schemas';
420
+
421
+ /** /app/new: create a team; its creator becomes a member. */
422
+ export class RegisterTeam extends RegisterTenant {
423
+ static getLabel(): string { return 'New team'; }
424
+ formSchema(schema: Schema): Schema { return schema.components([TextInput.make('name').required().maxLength(255)]); }
425
+ }
426
+
427
+ /** /app/<team>/profile: rename the current team. */
428
+ export class EditTeamProfile extends EditTenantProfile {
429
+ static getLabel(): string { return 'Team settings'; }
430
+ formSchema(schema: Schema): Schema { return schema.components([TextInput.make('name').required().maxLength(255)]); }
431
+ }
432
+ `;
433
+ if (starter)
434
+ files['src/app/resources/NoteResource.ts'] = `import { ObjectionAdapter } from '@navette-js/database';
435
+ import { Textarea, TextInput } from '@navette-js/forms';
436
+ import { DeleteBulkAction, Resource } from '@navette-js/panels';
437
+ import type { Schema } from '@navette-js/schemas';
438
+ import { TextColumn, type Table } from '@navette-js/tables';
439
+ import { Note } from '../../models/Note.ts';
440
+
441
+ // Team notes: the panel scopes them to the current team through their \`team_id\` column.
442
+ export class NoteResource extends Resource {
443
+ static slug = 'notes';
444
+ static modelLabel = 'note';
445
+ static navigationIcon = 'o-pencil-square';
446
+ static recordTitleAttribute = 'title';
447
+ static adapter = new ObjectionAdapter(Note);
448
+
449
+ static form(schema: Schema): Schema {
450
+ return schema.components([
451
+ TextInput.make('title').required().maxLength(255),
452
+ Textarea.make('body').rows(6).columnSpanFull(),
453
+ ]);
454
+ }
455
+
456
+ static table(table: Table<Note>): Table<Note> {
457
+ return table
458
+ .columns([
459
+ TextColumn.make<Note>('title').searchable().sortable(),
460
+ TextColumn.make<Note>('updated_at').dateTime().sortable(),
461
+ ])
462
+ .groupedBulkActions([DeleteBulkAction.make()]);
463
+ }
464
+ }
465
+ `;
466
+ }
467
+ else {
468
+ files['src/app/resources/.gitkeep'] = '';
469
+ }
470
+ }
471
+ if (!starter)
472
+ files['src/admin/widgets/.gitkeep'] = '';
473
+ // ------------------------------------------------------------------ server
474
+ files['src/server.ts'] = `// Entry point: runs the migrations, then serves every panel of src/panels.
475
+ import express from 'express';
476
+ import { mkdirSync } from 'node:fs';
477
+ import { discoverPanels, panelRouter } from '@navette-js/panels';
478
+ ${o.locale === 'fr' ? "import { setLocale } from '@navette-js/support/i18n';\n" : ''}import { UploadStore, WireRuntime } from '@navette-js/wire';
479
+ import { migrate } from './database.ts';
480
+
481
+ const key = process.env.APP_KEY;
482
+ if (!key) throw new Error('APP_KEY is missing: start the server with \`npm run dev\` (it reads .env), or copy .env.example to .env and set APP_KEY.');
483
+ ${o.locale === 'fr' ? "setLocale('fr');\n" : ''}mkdirSync('storage/public', { recursive: true });
484
+
485
+ for (const name of await migrate()) console.log(\`Migrated \${name}\`);
486
+
487
+ const app = express();
488
+ // Uploaded files that are public (FileUpload with the public disk).
489
+ app.use('/storage', express.static('storage/public', { dotfiles: 'deny', index: false }));
490
+ // Each panel has its own runtime (its own Livewire endpoint and components).
491
+ for (const panel of await discoverPanels(new URL('./panels/', import.meta.url))) {
492
+ app.use(panelRouter(panel, new WireRuntime({ key, uploads: new UploadStore('storage/tmp', key) })));
493
+ }
494
+ app.get('/', (_req, res) => res.redirect('/${o.userPanel ? 'app' : 'admin'}'));
495
+
496
+ const port = Number(process.env.PORT ?? ${o.port});
497
+ app.listen(port, '127.0.0.1', () => {
498
+ console.log(\`\\n \${process.env.APP_NAME ?? 'Navette'} is running:\`);
499
+ console.log(\` Admin panel http://127.0.0.1:\${port}/admin\`);
500
+ ${o.userPanel ? " console.log(` User panel http://127.0.0.1:${port}/app`);\n" : ''}});
501
+ `;
502
+ // ------------------------------------------------------------------ README
503
+ files['README.md'] = `# ${o.appName}
504
+
505
+ Built with [Navette](https://github.com/NavetteJS/navette), admin panels for Node.js.
506
+
507
+ ## Running
508
+
509
+ \`\`\`bash
510
+ npm run dev
511
+ \`\`\`
512
+
513
+ - Admin panel: http://127.0.0.1:${o.port}/admin — sign in with an \`admin\` user.
514
+ ${o.userPanel ? `- User panel: http://127.0.0.1:${o.port}/app — anyone can sign up${teams ? ', then create a team' : ''}.\n` : ''}
515
+ Create an admin user:
516
+
517
+ \`\`\`bash
518
+ npx navette make:user --admin
519
+ \`\`\`
520
+
521
+ In development, e-mails (password reset${o.userPanel ? ', email verification' : ''}) are written to \`storage/mail.log\`.
522
+
523
+ ## Project structure
524
+
525
+ | Path | |
526
+ |---|---|
527
+ | \`src/server.ts\` | Starts Express, runs the migrations, serves the panels |
528
+ | \`src/panels/\` | One file per panel (\`admin.ts\`${o.userPanel ? ', `app.ts`' : ''}) |
529
+ | \`src/admin/resources\`, \`pages\`, \`widgets\` | Discovered automatically by the admin panel |
530
+ ${o.userPanel ? '| `src/app/…` | The same for the user panel |\n' : ''}| \`src/models/\` | Objection models |
531
+ | \`src/migrations/\` | Database migrations, run at start-up |
532
+ | \`.env\` | Settings (\`APP_KEY\` is secret: never commit it) |
533
+
534
+ ## Generating code
535
+
536
+ \`\`\`bash
537
+ npx navette make:migration create_posts_table # then edit it and restart
538
+ npx navette make:resource Post --generate # form and table from the posts table
539
+ npx navette make:page Settings
540
+ npx navette make:widget SalesChart --type=chart
541
+ \`\`\`
542
+
543
+ Add \`--panel=app\` to target the user panel.
544
+ `;
545
+ return files;
546
+ }
package/package.json CHANGED
@@ -1,6 +1,49 @@
1
1
  {
2
2
  "name": "create-navette",
3
- "version": "0.0.0-stage",
4
- "stub": true,
5
- "description": "Temporary package placeholder for staged publishing"
6
- }
3
+ "version": "1.0.0-beta.2",
4
+ "description": "Create a Navette project: npm create navette@latest",
5
+ "license": "MIT",
6
+ "type": "module",
7
+ "exports": {
8
+ ".": {
9
+ "types": "./lib/index.d.ts",
10
+ "default": "./lib/index.js"
11
+ }
12
+ },
13
+ "types": "./lib/index.d.ts",
14
+ "bin": {
15
+ "create-navette": "./lib/index.js"
16
+ },
17
+ "files": [
18
+ "lib",
19
+ "LICENSE",
20
+ "README.md"
21
+ ],
22
+ "engines": {
23
+ "node": ">=24"
24
+ },
25
+ "repository": {
26
+ "type": "git",
27
+ "url": "git+https://github.com/NavetteJS/navette.git",
28
+ "directory": "packages/create"
29
+ },
30
+ "homepage": "https://github.com/NavetteJS/navette#readme",
31
+ "bugs": {
32
+ "url": "https://github.com/NavetteJS/navette/issues"
33
+ },
34
+ "keywords": [
35
+ "navette",
36
+ "admin",
37
+ "panel",
38
+ "livewire",
39
+ "express",
40
+ "node",
41
+ "cli",
42
+ "scaffold"
43
+ ],
44
+ "dependencies": {},
45
+ "publishConfig": {
46
+ "access": "public",
47
+ "tag": "beta"
48
+ }
49
+ }