@navette-js/cli 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
+ # @navette-js/cli
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
+ Command-line tools for Navette projects: migrations, users, and generators for resources, pages and widgets (npx navette).
4
+
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
+
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/cli.d.ts ADDED
@@ -0,0 +1,2 @@
1
+ #!/usr/bin/env node
2
+ export {};
package/lib/cli.js ADDED
@@ -0,0 +1,158 @@
1
+ #!/usr/bin/env node
2
+ var __rewriteRelativeImportExtension = (this && this.__rewriteRelativeImportExtension) || function (path, preserveJsx) {
3
+ if (typeof path === "string" && /^\.\.?\//.test(path)) {
4
+ return path.replace(/\.(tsx)$|((?:\.d)?)((?:\.[^./]+?)?)\.([cm]?)ts$/i, function (m, tsx, d, ext, cm) {
5
+ return tsx ? preserveJsx ? ".jsx" : ".js" : d && (!ext || !cm) ? m : (d + ext + "." + cm.toLowerCase() + "js");
6
+ });
7
+ }
8
+ return path;
9
+ };
10
+ // Project CLI (`npx navette …`): migrations, users, and generators for resources, pages and widgets.
11
+ // Run it at the root of a project created with `npm create navette`.
12
+ import { existsSync, mkdirSync, readdirSync, writeFileSync } from 'node:fs';
13
+ import { randomBytes } from 'node:crypto';
14
+ import { join, relative } from 'node:path';
15
+ import { createInterface } from 'node:readline/promises';
16
+ import { pathToFileURL } from 'node:url';
17
+ import { generateResource, generateMigration, generatePage, generateWidget, studly, tableName } from "./generators.js";
18
+ const cwd = process.cwd();
19
+ if (existsSync(join(cwd, '.env')))
20
+ process.loadEnvFile(join(cwd, '.env'));
21
+ const [command, ...rest] = process.argv.slice(2);
22
+ const flags = {};
23
+ const positional = [];
24
+ for (const arg of rest) {
25
+ const match = /^--([a-z-]+)(?:=(.*))?$/.exec(arg);
26
+ if (match)
27
+ flags[match[1]] = match[2] ?? true;
28
+ else
29
+ positional.push(arg);
30
+ }
31
+ const flag = (name) => (typeof flags[name] === 'string' ? flags[name] : undefined);
32
+ const say = (text = '') => process.stdout.write(text + '\n');
33
+ const fail = (text) => { process.stderr.write(`\n ✖ ${text}\n\n`); process.exit(1); };
34
+ /** The project's `src/database.ts` exports `db` (knex) and `migrate()`. */
35
+ async function project() {
36
+ const file = join(cwd, 'src', 'database.ts');
37
+ if (!existsSync(file))
38
+ fail('No src/database.ts here: run this command at the root of a Navette project (created with `npm create navette`).');
39
+ return import(__rewriteRelativeImportExtension(pathToFileURL(file).href));
40
+ }
41
+ async function ask(question, fallback = '') {
42
+ if (!process.stdin.isTTY)
43
+ return fallback;
44
+ const rl = createInterface({ input: process.stdin, output: process.stdout });
45
+ const answer = (await rl.question(` ${question}${fallback ? ` (${fallback})` : ''}: `)).trim();
46
+ rl.close();
47
+ return answer || fallback;
48
+ }
49
+ /** Never overwrites an existing file unless `--force` is given. */
50
+ function write(file, content) {
51
+ if (existsSync(file) && !flags.force)
52
+ fail(`${relative(cwd, file)} already exists (use --force to overwrite).`);
53
+ mkdirSync(join(file, '..'), { recursive: true });
54
+ writeFileSync(file, content);
55
+ say(` ✔ Created ${relative(cwd, file).replaceAll('\\', '/')}`);
56
+ }
57
+ const panelOf = () => {
58
+ const panel = flag('panel') ?? 'admin';
59
+ if (!/^[a-z][a-z0-9-]*$/.test(panel))
60
+ fail(`Invalid panel id: ${panel}`);
61
+ return panel;
62
+ };
63
+ const commands = {
64
+ migrate: {
65
+ usage: 'navette migrate Run the pending migrations of src/migrations',
66
+ async run() {
67
+ const { migrate, db } = await project();
68
+ const ran = await migrate();
69
+ say(ran.length ? ran.map((name) => ` ✔ Migrated ${name}`).join('\n') : ' Nothing to migrate.');
70
+ await db.destroy();
71
+ },
72
+ },
73
+ 'make:migration': {
74
+ usage: 'navette make:migration <name> Create a migration file (e.g. create_posts_table)',
75
+ async run() {
76
+ const name = (positional[0] ?? fail('Give the migration a name, e.g. create_posts_table')).replace(/[^a-z0-9_]/gi, '_').toLowerCase();
77
+ const stamp = new Date().toISOString().replace(/\D/g, '').slice(0, 14);
78
+ write(join(cwd, 'src', 'migrations', `${stamp}_${name}.ts`), generateMigration(name));
79
+ },
80
+ },
81
+ 'make:user': {
82
+ usage: 'navette make:user [--admin] [--name=] [--email=] [--password=] Create a user (role admin with --admin)',
83
+ async run() {
84
+ const { db, migrate } = await project();
85
+ await migrate();
86
+ const name = flag('name') ?? (await ask('Name', 'Admin'));
87
+ const email = flag('email') ?? (await ask('Email address', 'admin@example.com'));
88
+ if (!/^[^\s@]+@[^\s@]+$/.test(email))
89
+ fail(`Invalid email address: ${email}`);
90
+ let password = flag('password') ?? (await ask('Password (leave empty to generate one)'));
91
+ const generated = !password;
92
+ if (generated)
93
+ password = randomBytes(12).toString('base64url');
94
+ if (password.length < 8)
95
+ fail('The password must be at least 8 characters.');
96
+ if (await db('users').where('email', email).first())
97
+ fail(`A user with the email ${email} already exists.`);
98
+ const { knexAuthProvider } = await import('@navette-js/panels');
99
+ const user = await knexAuthProvider((table) => db(table)).create({ name, email, password });
100
+ const now = new Date().toISOString().slice(0, 19).replace('T', ' ');
101
+ 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}` : ''}`);
103
+ await db.destroy();
104
+ },
105
+ },
106
+ 'make:resource': {
107
+ usage: 'navette make:resource <Name> [--panel=admin] [--generate] [--table=] Create a resource (form and table from the database with --generate)',
108
+ async run() {
109
+ const model = studly((positional[0] ?? fail('Give the resource a name, e.g. Post')).replace(/Resource$/, ''));
110
+ const table = flag('table') ?? tableName(model);
111
+ let columns = null;
112
+ if (flags.generate) {
113
+ const { db, migrate } = await project();
114
+ await migrate();
115
+ if (!(await db.schema.hasTable(table)))
116
+ fail(`Table "${table}" not found. Create it with a migration first (npx navette make:migration create_${table}_table), or pass --table=.`);
117
+ const info = await db(table).columnInfo();
118
+ columns = Object.entries(info).map(([name, column]) => ({ name, type: String(column.type ?? ''), nullable: column.nullable !== false }));
119
+ await db.destroy();
120
+ }
121
+ const panel = panelOf();
122
+ if (!existsSync(join(cwd, 'src', 'models', `${model}.ts`)))
123
+ write(join(cwd, 'src', 'models', `${model}.ts`), generateResource(model, table, columns).model);
124
+ write(join(cwd, 'src', panel, 'resources', `${model}Resource.ts`), generateResource(model, table, columns).resource);
125
+ say(`\n Restart the server: the resource is discovered from src/${panel}/resources.`);
126
+ },
127
+ },
128
+ 'make:page': {
129
+ usage: 'navette make:page <Name> [--panel=admin] Create a custom page',
130
+ async run() {
131
+ const name = studly(positional[0] ?? fail('Give the page a name, e.g. Settings'));
132
+ write(join(cwd, 'src', panelOf(), 'pages', `${name}.ts`), generatePage(name));
133
+ },
134
+ },
135
+ 'make:widget': {
136
+ usage: 'navette make:widget <Name> [--panel=admin] [--type=stats|chart|table] Create a dashboard widget',
137
+ async run() {
138
+ const name = studly(positional[0] ?? fail('Give the widget a name, e.g. OrdersChart'));
139
+ const type = flag('type') ?? 'stats';
140
+ if (!['stats', 'chart', 'table'].includes(type))
141
+ fail('--type must be stats, chart or table.');
142
+ write(join(cwd, 'src', panelOf(), 'widgets', `${name}.ts`), generateWidget(name, type));
143
+ },
144
+ },
145
+ };
146
+ if (!command || command === 'help' || command === '--help' || !commands[command]) {
147
+ if (command && command !== 'help' && command !== '--help')
148
+ say(`\n Unknown command: ${command}`);
149
+ say('\n Navette CLI\n');
150
+ for (const { usage } of Object.values(commands))
151
+ say(` ${usage}`);
152
+ const panels = existsSync(join(cwd, 'src')) ? readdirSync(join(cwd, 'src'), { withFileTypes: true }).filter((entry) => entry.isDirectory() && existsSync(join(cwd, 'src', entry.name, 'resources'))).map((entry) => entry.name) : [];
153
+ if (panels.length)
154
+ say(`\n Panels in this project: ${panels.join(', ')}`);
155
+ say('');
156
+ process.exit(command && command !== 'help' && command !== '--help' ? 1 : 0);
157
+ }
158
+ await commands[command].run();
@@ -0,0 +1,17 @@
1
+ export type ColumnInfo = {
2
+ name: string;
3
+ type: string;
4
+ nullable: boolean;
5
+ };
6
+ /** `blog post` → `BlogPost`. */
7
+ export declare const studly: (value: string) => string;
8
+ /** `BlogPost` → `blog_posts`. */
9
+ export declare const tableName: (model: string) => string;
10
+ /** Resource and model. Without known columns (no `--generate`), the form and table are placeholders to fill in. */
11
+ export declare function generateResource(model: string, table: string, columns: ColumnInfo[] | null): {
12
+ model: string;
13
+ resource: string;
14
+ };
15
+ export declare function generateMigration(name: string): string;
16
+ export declare function generatePage(name: string): string;
17
+ export declare function generateWidget(name: string, type: 'stats' | 'chart' | 'table'): string;
@@ -0,0 +1,177 @@
1
+ // CLI file generators: resources (form and table inferred from the database columns), pages, widgets, migrations.
2
+ /** `blog post` → `BlogPost`. */
3
+ export const studly = (value) => value.replace(/[^A-Za-z0-9]+(.)?/g, (_m, c) => (c ? c.toUpperCase() : '')).replace(/^./, (c) => c.toUpperCase());
4
+ const snake = (value) => value.replace(/([a-z0-9])([A-Z])/g, '$1_$2').toLowerCase();
5
+ const plural = (value) => (/[^aeiou]y$/.test(value) ? value.slice(0, -1) + 'ies' : /(s|x|z|ch|sh)$/.test(value) ? value + 'es' : value + 's');
6
+ /** `BlogPost` → `blog_posts`. */
7
+ export const tableName = (model) => plural(snake(model));
8
+ const headline = (column) => column.replace(/_id$/, '').replace(/_/g, ' ').replace(/^./, (c) => c.toUpperCase());
9
+ /** Columns never edited in a form. */
10
+ const SKIPPED = new Set(['id', 'created_at', 'updated_at', 'deleted_at', 'password', 'remember_token', 'email_verified_at']);
11
+ function kindOf(column) {
12
+ const type = column.type.toLowerCase();
13
+ if (/bool/.test(type) || (type === 'tinyint' && /^(is|has|can)_/.test(column.name)))
14
+ return 'boolean';
15
+ if (/^date$/.test(type))
16
+ return 'date';
17
+ if (/datetime|timestamp/.test(type))
18
+ return 'dateTime';
19
+ if (/int|decimal|numeric|float|double|real/.test(type))
20
+ return 'number';
21
+ if (/text|json|clob/.test(type))
22
+ return 'longText';
23
+ if (/email/.test(column.name))
24
+ return 'email';
25
+ return 'text';
26
+ }
27
+ function formField(column) {
28
+ const label = column.name;
29
+ const required = column.nullable ? '' : '.required()';
30
+ switch (kindOf(column)) {
31
+ case 'boolean': return `Toggle.make('${label}')`;
32
+ case 'date': return `DatePicker.make('${label}')${required}`;
33
+ case 'dateTime': return `DateTimePicker.make('${label}')${required}`;
34
+ case 'number': return `TextInput.make('${label}').numeric()${required}`;
35
+ case 'longText': return `Textarea.make('${label}')${required}.columnSpanFull()`;
36
+ case 'email': return `TextInput.make('${label}').email()${required}.maxLength(255)`;
37
+ default: return `TextInput.make('${label}')${required}.maxLength(255)`;
38
+ }
39
+ }
40
+ function tableColumn(column) {
41
+ const name = column.name;
42
+ switch (kindOf(column)) {
43
+ case 'boolean': return `IconColumn.make('${name}').boolean()`;
44
+ case 'date': return `TextColumn.make('${name}').date().sortable()`;
45
+ case 'dateTime': return `TextColumn.make('${name}').dateTime().sortable()`;
46
+ case 'number': return `TextColumn.make('${name}').numeric().sortable()`;
47
+ case 'longText': return `TextColumn.make('${name}').limit(50)`;
48
+ default: return `TextColumn.make('${name}').searchable().sortable()`;
49
+ }
50
+ }
51
+ /** Resource and model. Without known columns (no `--generate`), the form and table are placeholders to fill in. */
52
+ export function generateResource(model, table, columns) {
53
+ const editable = (columns ?? []).filter((column) => !SKIPPED.has(column.name));
54
+ const titleAttribute = editable.find((column) => ['name', 'title', 'label', 'subject'].includes(column.name))?.name ?? null;
55
+ const kinds = new Set(editable.map(kindOf));
56
+ const formImports = ['TextInput', ...(kinds.has('boolean') ? ['Toggle'] : []), ...(kinds.has('date') ? ['DatePicker'] : []), ...(kinds.has('dateTime') ? ['DateTimePicker'] : []), ...(kinds.has('longText') ? ['Textarea'] : [])];
57
+ const tableImports = ['TextColumn', ...(kinds.has('boolean') ? ['IconColumn'] : []), 'type Table'];
58
+ const timestamps = (columns ?? []).some((column) => column.name === 'created_at');
59
+ const modelCode = `import { Model } from '@navette-js/database';
60
+
61
+ export class ${model} extends Model {
62
+ static tableName = '${table}';
63
+ ${timestamps ? `
64
+ $beforeInsert(): void { (this as any).created_at = (this as any).updated_at = new Date().toISOString().slice(0, 19).replace('T', ' '); }
65
+ $beforeUpdate(): void { (this as any).updated_at = new Date().toISOString().slice(0, 19).replace('T', ' '); }
66
+ ` : ''}}
67
+ `;
68
+ const fields = editable.length ? editable.map((column) => ` ${formField(column)},`).join('\n') : ` TextInput.make('name').required().maxLength(255),`;
69
+ const cols = editable.length ? editable.filter((column) => kindOf(column) !== 'longText' || editable.length < 4).slice(0, 6).map((column) => ` ${tableColumn(column)},`).join('\n') : ` TextColumn.make('name').searchable().sortable(),`;
70
+ const createdColumn = timestamps ? `\n TextColumn.make('created_at').dateTime().sortable().toggleable(true, { isToggledHiddenByDefault: true }),` : '';
71
+ const resourceCode = `import { ObjectionAdapter } from '@navette-js/database';
72
+ import { ${formImports.join(', ')} } from '@navette-js/forms';
73
+ import { DeleteBulkAction, Resource } from '@navette-js/panels';
74
+ import type { Schema } from '@navette-js/schemas';
75
+ import { ${tableImports.join(', ')} } from '@navette-js/tables';
76
+ import { ${model} } from '../../models/${model}.ts';
77
+
78
+ export class ${model}Resource extends Resource {
79
+ static slug = '${table.replace(/_/g, '-')}';
80
+ static modelLabel = '${snake(model).replace(/_/g, ' ')}';
81
+ static navigationIcon = 'o-rectangle-stack';
82
+ ${titleAttribute ? ` static recordTitleAttribute = '${titleAttribute}';\n` : ''} static adapter = new ObjectionAdapter(${model});
83
+
84
+ static form(schema: Schema): Schema {
85
+ return schema.components([
86
+ ${fields}
87
+ ]);
88
+ }
89
+
90
+ static table(table: Table<${model}>): Table<${model}> {
91
+ return table
92
+ .columns([
93
+ ${cols}${createdColumn}
94
+ ])
95
+ .groupedBulkActions([DeleteBulkAction.make()]);
96
+ }
97
+ }
98
+ `;
99
+ return { model: modelCode, resource: resourceCode };
100
+ }
101
+ export function generateMigration(name) {
102
+ const created = /^create_(\w+)_table$/.exec(name)?.[1];
103
+ return `import type { Knex } from '@navette-js/database';
104
+
105
+ export async function up(db: Knex): Promise<void> {
106
+ ${created ? ` await db.schema.createTable('${created}', (table) => {
107
+ table.increments('id');
108
+ table.string('name');
109
+ table.timestamps();
110
+ });` : ` // await db.schema.alterTable('table', (table) => { … });`}
111
+ }
112
+ `;
113
+ }
114
+ export function generatePage(name) {
115
+ const slug = snake(name).replace(/_/g, '-');
116
+ return `import { Page } from '@navette-js/panels';
117
+ import { Section, Text, type Schema } from '@navette-js/schemas';
118
+
119
+ export class ${name} extends Page {
120
+ static slug = '${slug}';
121
+ static title = '${headline(snake(name))}';
122
+ static navigationIcon = 'o-document-text';
123
+
124
+ contentSchema(schema: Schema): Schema {
125
+ return schema.components([
126
+ Section.make('${headline(snake(name))}').schema([
127
+ Text.make('Start building your page here.'),
128
+ ]),
129
+ ]);
130
+ }
131
+ }
132
+ `;
133
+ }
134
+ export function generateWidget(name, type) {
135
+ if (type === 'chart')
136
+ return `import { ChartWidget, type ChartData } from '@navette-js/panels';
137
+
138
+ export class ${name} extends ChartWidget {
139
+ static heading = '${headline(snake(name))}';
140
+
141
+ getType(): string { return 'line'; }
142
+
143
+ async getData(): Promise<ChartData> {
144
+ return {
145
+ datasets: [{ label: 'Items', data: [3, 8, 5, 12, 9, 14] }],
146
+ labels: ['Jan', 'Feb', 'Mar', 'Apr', 'May', 'Jun'],
147
+ };
148
+ }
149
+ }
150
+ `;
151
+ if (type === 'table')
152
+ return `import { TableWidget } from '@navette-js/panels';
153
+ import type { DataAdapter } from '@navette-js/database';
154
+ import { TextColumn, type Table } from '@navette-js/tables';
155
+
156
+ export class ${name} extends TableWidget {
157
+ static heading = '${headline(snake(name))}';
158
+
159
+ // Return the adapter of the records to list, e.g. \`new ObjectionAdapter(Order)\`.
160
+ getDataAdapterForTable(): DataAdapter { throw new Error('${name}: return a data adapter from getDataAdapterForTable().'); }
161
+
162
+ table(table: Table<any>): Table<any> {
163
+ return table.columns([TextColumn.make('name')]);
164
+ }
165
+ }
166
+ `;
167
+ return `import { Stat, StatsOverviewWidget } from '@navette-js/panels';
168
+
169
+ export class ${name} extends StatsOverviewWidget {
170
+ async getStats(): Promise<Stat[]> {
171
+ return [
172
+ Stat.make('Users', 0),
173
+ ];
174
+ }
175
+ }
176
+ `;
177
+ }
package/package.json CHANGED
@@ -1,6 +1,52 @@
1
1
  {
2
2
  "name": "@navette-js/cli",
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": "Command-line tools for Navette projects: migrations, users, and generators for resources, pages and widgets (npx navette).",
5
+ "license": "MIT",
6
+ "type": "module",
7
+ "exports": {
8
+ ".": {
9
+ "types": "./lib/generators.d.ts",
10
+ "default": "./lib/generators.js"
11
+ }
12
+ },
13
+ "types": "./lib/generators.d.ts",
14
+ "bin": {
15
+ "navette": "./lib/cli.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/cli"
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
+ "@navette-js/panels": "1.0.0-beta.2",
46
+ "@navette-js/database": "1.0.0-beta.2"
47
+ },
48
+ "publishConfig": {
49
+ "access": "public",
50
+ "tag": "beta"
51
+ }
52
+ }