millas 0.2.13 → 0.2.14
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/package.json +6 -3
- package/src/admin/Admin.js +107 -1027
- package/src/admin/AdminAuth.js +1 -1
- package/src/admin/ViewContext.js +1 -1
- package/src/admin/handlers/ActionHandler.js +103 -0
- package/src/admin/handlers/ApiHandler.js +113 -0
- package/src/admin/handlers/AuthHandler.js +76 -0
- package/src/admin/handlers/ExportHandler.js +70 -0
- package/src/admin/handlers/InlineHandler.js +71 -0
- package/src/admin/handlers/PageHandler.js +351 -0
- package/src/admin/resources/AdminResource.js +22 -1
- package/src/admin/static/SelectFilter2.js +34 -0
- package/src/admin/static/actions.js +201 -0
- package/src/admin/static/admin.css +7 -0
- package/src/admin/static/change_form.js +585 -0
- package/src/admin/static/core.js +128 -0
- package/src/admin/static/login.js +76 -0
- package/src/admin/static/vendor/bi/bootstrap-icons.min.css +5 -0
- package/src/admin/static/vendor/bi/fonts/bootstrap-icons.woff +0 -0
- package/src/admin/static/vendor/bi/fonts/bootstrap-icons.woff2 +0 -0
- package/src/admin/static/vendor/jquery.min.js +2 -0
- package/src/admin/views/layouts/base.njk +30 -113
- package/src/admin/views/pages/detail.njk +10 -9
- package/src/admin/views/pages/form.njk +4 -4
- package/src/admin/views/pages/list.njk +11 -193
- package/src/admin/views/pages/login.njk +19 -64
- package/src/admin/views/partials/form-field.njk +1 -1
- package/src/admin/views/partials/form-scripts.njk +4 -478
- package/src/admin/views/partials/form-widget.njk +10 -10
- package/src/ai/AITokenBudget.js +1 -1
- package/src/auth/Auth.js +112 -3
- package/src/auth/AuthMiddleware.js +18 -15
- package/src/auth/Hasher.js +15 -43
- package/src/cli.js +3 -0
- package/src/commands/call.js +190 -0
- package/src/commands/createsuperuser.js +3 -4
- package/src/commands/key.js +97 -0
- package/src/commands/make.js +16 -2
- package/src/commands/new.js +16 -1
- package/src/commands/serve.js +5 -5
- package/src/console/Command.js +337 -0
- package/src/console/CommandLoader.js +165 -0
- package/src/console/index.js +6 -0
- package/src/container/AppInitializer.js +48 -1
- package/src/container/Application.js +3 -1
- package/src/container/HttpServer.js +0 -1
- package/src/container/MillasConfig.js +48 -0
- package/src/controller/Controller.js +13 -11
- package/src/core/docs.js +6 -0
- package/src/core/foundation.js +8 -0
- package/src/core/http.js +20 -10
- package/src/core/validation.js +58 -27
- package/src/docs/Docs.js +268 -0
- package/src/docs/DocsServiceProvider.js +80 -0
- package/src/docs/SchemaInferrer.js +131 -0
- package/src/docs/handlers/ApiHandler.js +305 -0
- package/src/docs/handlers/PageHandler.js +47 -0
- package/src/docs/index.js +13 -0
- package/src/docs/resources/ApiResource.js +402 -0
- package/src/docs/static/docs.css +723 -0
- package/src/docs/static/docs.js +1181 -0
- package/src/encryption/Encrypter.js +381 -0
- package/src/facades/Auth.js +5 -2
- package/src/facades/Crypt.js +166 -0
- package/src/facades/Docs.js +43 -0
- package/src/facades/Mail.js +1 -1
- package/src/http/MillasRequest.js +7 -31
- package/src/http/RequestContext.js +11 -7
- package/src/http/SecurityBootstrap.js +24 -2
- package/src/http/Shape.js +168 -0
- package/src/http/adapters/ExpressAdapter.js +9 -5
- package/src/middleware/CorsMiddleware.js +3 -0
- package/src/middleware/ThrottleMiddleware.js +10 -7
- package/src/orm/model/Model.js +14 -1
- package/src/providers/EncryptionServiceProvider.js +66 -0
- package/src/router/MiddlewareRegistry.js +79 -54
- package/src/router/Route.js +9 -4
- package/src/router/RouteEntry.js +91 -0
- package/src/router/Router.js +71 -1
- package/src/scaffold/maker.js +138 -1
- package/src/scaffold/templates.js +12 -0
- package/src/serializer/Serializer.js +239 -0
- package/src/support/Str.js +1080 -0
- package/src/validation/BaseValidator.js +45 -5
- package/src/validation/Validator.js +67 -61
- package/src/validation/types.js +490 -0
- package/src/middleware/AuthMiddleware.js +0 -46
- package/src/middleware/MiddlewareRegistry.js +0 -106
package/src/auth/Auth.js
CHANGED
|
@@ -19,23 +19,35 @@ const HttpError = require('../errors/HttpError');
|
|
|
19
19
|
* password: 'secret123',
|
|
20
20
|
* });
|
|
21
21
|
*
|
|
22
|
-
* // Login
|
|
22
|
+
* // Login — throws 401 on failure
|
|
23
23
|
* const { user, token } = await Auth.login('alice@example.com', 'secret123');
|
|
24
24
|
*
|
|
25
|
+
* // Attempt — returns user | null (Laravel/Rails style, never throws)
|
|
26
|
+
* const user = await Auth.attempt(email, password);
|
|
27
|
+
* if (!user) return this.unauthorized('Invalid credentials');
|
|
28
|
+
*
|
|
25
29
|
* // Verify a token
|
|
26
30
|
* const payload = Auth.verify(token);
|
|
27
31
|
*
|
|
28
32
|
* // Get logged-in user from a request
|
|
29
33
|
* const user = await Auth.user(req);
|
|
30
34
|
*
|
|
35
|
+
* // Revoke a token (adds to in-memory denylist — resets on restart)
|
|
36
|
+
* await Auth.revokeToken(user, token);
|
|
37
|
+
*
|
|
31
38
|
* // Check password
|
|
32
39
|
* const ok = await Auth.checkPassword('plain', user.password);
|
|
33
40
|
*/
|
|
34
41
|
class Auth {
|
|
35
42
|
constructor() {
|
|
36
|
-
this._jwt
|
|
37
|
-
this._config
|
|
43
|
+
this._jwt = null;
|
|
44
|
+
this._config = null;
|
|
38
45
|
this._UserModel = null;
|
|
46
|
+
// In-memory token denylist for revokeToken().
|
|
47
|
+
// Resets on server restart — sufficient for short-lived JWTs.
|
|
48
|
+
// For persistent revocation across restarts/processes, set a cache
|
|
49
|
+
// store via Auth.configure({ revocationStore: redisClient }).
|
|
50
|
+
this._revokedTokens = new Set();
|
|
39
51
|
}
|
|
40
52
|
|
|
41
53
|
// ─── Configuration ───────────────────────────────────────────────────────
|
|
@@ -124,6 +136,41 @@ class Auth {
|
|
|
124
136
|
return { user, token, refreshToken };
|
|
125
137
|
}
|
|
126
138
|
|
|
139
|
+
/**
|
|
140
|
+
* Attempt to authenticate with email + password.
|
|
141
|
+
*
|
|
142
|
+
* Laravel / Rails style — returns the user on success, null on failure.
|
|
143
|
+
* Never throws for bad credentials; throws only for unexpected errors.
|
|
144
|
+
*
|
|
145
|
+
* The caller is responsible for status checks (banned, inactive, etc.)
|
|
146
|
+
* because attempt() intentionally skips them — it only validates identity.
|
|
147
|
+
*
|
|
148
|
+
* const user = await Auth.attempt(email, password);
|
|
149
|
+
* if (!user) return this.unauthorized('Invalid email or password.');
|
|
150
|
+
* if (user.status === 'banned') return this.forbidden('Account suspended.');
|
|
151
|
+
* const token = Auth.issueToken(user);
|
|
152
|
+
*
|
|
153
|
+
* @param {string} email
|
|
154
|
+
* @param {string} password
|
|
155
|
+
* @returns {Promise<object|null>} user instance or null
|
|
156
|
+
*/
|
|
157
|
+
async attempt(email, password) {
|
|
158
|
+
this._requireUserModel();
|
|
159
|
+
|
|
160
|
+
const user = await this._UserModel.findBy('email', email);
|
|
161
|
+
if (!user) return null;
|
|
162
|
+
|
|
163
|
+
const ok = await Hasher.check(password, user.password);
|
|
164
|
+
if (!ok) return null;
|
|
165
|
+
|
|
166
|
+
// Record last login (fire-and-forget)
|
|
167
|
+
try {
|
|
168
|
+
await this._UserModel.where('id', user.id).update({ last_login: new Date().toISOString() });
|
|
169
|
+
} catch { /* non-fatal */ }
|
|
170
|
+
|
|
171
|
+
return user;
|
|
172
|
+
}
|
|
173
|
+
|
|
127
174
|
/**
|
|
128
175
|
* Verify and decode a token string.
|
|
129
176
|
* Throws 401 if expired or invalid.
|
|
@@ -176,6 +223,68 @@ class Auth {
|
|
|
176
223
|
return u;
|
|
177
224
|
}
|
|
178
225
|
|
|
226
|
+
/**
|
|
227
|
+
* Revoke a token so it cannot be used again.
|
|
228
|
+
*
|
|
229
|
+
* Adds the token's unique identifier (jti / sub+iat) to an in-memory denylist.
|
|
230
|
+
* The denylist resets on server restart — acceptable for short-lived JWTs (≤7d).
|
|
231
|
+
*
|
|
232
|
+
* For persistence across restarts or multi-process deployments, configure a
|
|
233
|
+
* cache store (Redis) in config/auth.js:
|
|
234
|
+
*
|
|
235
|
+
* guards: { jwt: { revocationStore: redisClient } }
|
|
236
|
+
*
|
|
237
|
+
* The token string can be passed directly, or extracted from the request
|
|
238
|
+
* by AuthMiddleware and attached to req.token / ctx.token.
|
|
239
|
+
*
|
|
240
|
+
* // In a logout controller:
|
|
241
|
+
* await Auth.revokeToken(user, ctx.token);
|
|
242
|
+
*
|
|
243
|
+
* @param {object} user — the authenticated user (used to scope the key)
|
|
244
|
+
* @param {string} token — the raw JWT string to revoke
|
|
245
|
+
*/
|
|
246
|
+
async revokeToken(user, token) {
|
|
247
|
+
if (!token) return;
|
|
248
|
+
|
|
249
|
+
// Decode without verifying — we want to revoke even if it's expiring soon
|
|
250
|
+
const payload = this._jwt.decode(token);
|
|
251
|
+
if (!payload) return;
|
|
252
|
+
|
|
253
|
+
// Use jti if present, otherwise fall back to sub+iat which is unique per-issue
|
|
254
|
+
const key = payload.jti || `${payload.sub || user?.id}:${payload.iat}`;
|
|
255
|
+
this._revokedTokens.add(key);
|
|
256
|
+
|
|
257
|
+
// Prune stale entries occasionally to prevent unbounded growth
|
|
258
|
+
if (this._revokedTokens.size > 10000) {
|
|
259
|
+
this._pruneExpiredRevocations();
|
|
260
|
+
}
|
|
261
|
+
}
|
|
262
|
+
|
|
263
|
+
/**
|
|
264
|
+
* Check whether a token has been revoked.
|
|
265
|
+
* Called automatically by AuthMiddleware on every authenticated request.
|
|
266
|
+
*
|
|
267
|
+
* @param {string} token — raw JWT string
|
|
268
|
+
* @returns {boolean}
|
|
269
|
+
*/
|
|
270
|
+
isRevoked(token) {
|
|
271
|
+
if (!token || this._revokedTokens.size === 0) return false;
|
|
272
|
+
const payload = this._jwt.decode(token);
|
|
273
|
+
if (!payload) return false;
|
|
274
|
+
const key = payload.jti || `${payload.sub}:${payload.iat}`;
|
|
275
|
+
return this._revokedTokens.has(key);
|
|
276
|
+
}
|
|
277
|
+
|
|
278
|
+
_pruneExpiredRevocations() {
|
|
279
|
+
// No expiry metadata stored — just trim the oldest half as a safety valve.
|
|
280
|
+
// In production use a Redis TTL-based store instead.
|
|
281
|
+
const entries = [...this._revokedTokens];
|
|
282
|
+
const half = Math.floor(entries.length / 2);
|
|
283
|
+
for (let i = 0; i < half; i++) {
|
|
284
|
+
this._revokedTokens.delete(entries[i]);
|
|
285
|
+
}
|
|
286
|
+
}
|
|
287
|
+
|
|
179
288
|
/**
|
|
180
289
|
* Hash a plain-text password.
|
|
181
290
|
*/
|
|
@@ -11,40 +11,43 @@ const Auth = require('./Auth');
|
|
|
11
11
|
* Reads the Bearer token from the Authorization header,
|
|
12
12
|
* verifies it, loads the user, and attaches them to req.user.
|
|
13
13
|
*
|
|
14
|
-
* Uses the Millas middleware signature: handle(
|
|
14
|
+
* Uses the Millas middleware signature: handle(ctx, next)
|
|
15
15
|
* No Express res — returns a MillasResponse or calls next().
|
|
16
16
|
*/
|
|
17
17
|
class AuthMiddleware extends Middleware {
|
|
18
|
-
async handle({
|
|
19
|
-
const header = headers
|
|
20
|
-
|
|
18
|
+
async handle({ req, headers }, next) {
|
|
19
|
+
const header = headers?.authorization || headers?.Authorization;
|
|
20
|
+
//
|
|
21
21
|
if (!header) {
|
|
22
22
|
throw new HttpError(401, 'Authorization header missing');
|
|
23
23
|
}
|
|
24
|
-
|
|
24
|
+
//
|
|
25
25
|
if (!header.startsWith('Bearer ')) {
|
|
26
26
|
throw new HttpError(401, 'Invalid authorization format. Use: Bearer <token>');
|
|
27
27
|
}
|
|
28
|
-
|
|
28
|
+
//
|
|
29
29
|
const token = header.slice(7);
|
|
30
30
|
if (!token) {
|
|
31
31
|
throw new HttpError(401, 'Token is empty');
|
|
32
32
|
}
|
|
33
|
-
|
|
33
|
+
//
|
|
34
|
+
if (Auth.isRevoked(token)) {
|
|
35
|
+
throw new HttpError(401, 'Token has been revoked');
|
|
36
|
+
}
|
|
37
|
+
//
|
|
34
38
|
const payload = Auth.verify(token);
|
|
35
|
-
|
|
39
|
+
//
|
|
36
40
|
let user;
|
|
37
41
|
try {
|
|
38
|
-
user = await Auth.user(req
|
|
39
|
-
} catch {
|
|
40
|
-
throw new HttpError(401, 'Authentication
|
|
42
|
+
user = await Auth.user(req);
|
|
43
|
+
} catch (err) {
|
|
44
|
+
throw new HttpError(401, 'Authentication failed: ' + err.message);
|
|
41
45
|
}
|
|
42
|
-
|
|
46
|
+
//
|
|
43
47
|
if (!user) {
|
|
44
48
|
throw new HttpError(401, 'User not found or has been deleted');
|
|
45
49
|
}
|
|
46
|
-
|
|
47
|
-
// Attach to the underlying request so downstream handlers see req.user
|
|
50
|
+
//
|
|
48
51
|
req.raw.user = user;
|
|
49
52
|
req.raw.token = token;
|
|
50
53
|
req.raw.tokenPayload = payload;
|
|
@@ -53,4 +56,4 @@ class AuthMiddleware extends Middleware {
|
|
|
53
56
|
}
|
|
54
57
|
}
|
|
55
58
|
|
|
56
|
-
module.exports = AuthMiddleware;
|
|
59
|
+
module.exports = AuthMiddleware;
|
package/src/auth/Hasher.js
CHANGED
|
@@ -1,51 +1,23 @@
|
|
|
1
1
|
'use strict';
|
|
2
2
|
|
|
3
|
-
const bcrypt = require('bcryptjs');
|
|
4
|
-
|
|
5
3
|
/**
|
|
6
4
|
* Hasher
|
|
7
5
|
*
|
|
8
|
-
*
|
|
6
|
+
* Thin re-export of the canonical Hash singleton from hashing/Hash.js.
|
|
7
|
+
*
|
|
8
|
+
* All password hashing in the framework — Auth.register(), Auth.login(),
|
|
9
|
+
* Auth.attempt(), AdminAuth.login(), createsuperuser CLI — flows through
|
|
10
|
+
* this single file, which delegates to HashManager (hashing/Hash.js).
|
|
11
|
+
*
|
|
12
|
+
* This means one algorithm, one rounds config, one place to change either.
|
|
13
|
+
*
|
|
14
|
+
* Developers who need hashing directly should prefer the Hash facade:
|
|
15
|
+
* const { Hash } = require('millas/facades/Hash');
|
|
9
16
|
*
|
|
10
|
-
*
|
|
11
|
-
*
|
|
12
|
-
* const ok = await Hasher.check('my-password', hash);
|
|
17
|
+
* Internal framework code uses this file so it works before the container
|
|
18
|
+
* is booted (CLI commands, early bootstrap).
|
|
13
19
|
*/
|
|
14
|
-
|
|
15
|
-
constructor(rounds = 12) {
|
|
16
|
-
this.rounds = rounds;
|
|
17
|
-
}
|
|
18
|
-
|
|
19
|
-
/**
|
|
20
|
-
* Hash a plain-text password.
|
|
21
|
-
* @param {string} plain
|
|
22
|
-
* @returns {Promise<string>}
|
|
23
|
-
*/
|
|
24
|
-
async make(plain) {
|
|
25
|
-
return bcrypt.hash(String(plain), this.rounds);
|
|
26
|
-
}
|
|
27
|
-
|
|
28
|
-
/**
|
|
29
|
-
* Verify a plain-text password against a hash.
|
|
30
|
-
* @param {string} plain
|
|
31
|
-
* @param {string} hash
|
|
32
|
-
* @returns {Promise<boolean>}
|
|
33
|
-
*/
|
|
34
|
-
async check(plain, hash) {
|
|
35
|
-
if (!plain || !hash) return false;
|
|
36
|
-
return bcrypt.compare(String(plain), String(hash));
|
|
37
|
-
}
|
|
38
|
-
|
|
39
|
-
/**
|
|
40
|
-
* Determine if a value needs to be re-hashed
|
|
41
|
-
* (e.g. rounds have changed).
|
|
42
|
-
*/
|
|
43
|
-
needsRehash(hash) {
|
|
44
|
-
const rounds = bcrypt.getRounds(hash);
|
|
45
|
-
return rounds !== this.rounds;
|
|
46
|
-
}
|
|
47
|
-
}
|
|
20
|
+
const Hash = require('../hashing/Hash');
|
|
48
21
|
|
|
49
|
-
|
|
50
|
-
module.exports =
|
|
51
|
-
module.exports.Hasher = Hasher;
|
|
22
|
+
module.exports = Hash;
|
|
23
|
+
module.exports.Hasher = Hash.constructor;
|
package/src/cli.js
CHANGED
|
@@ -5,6 +5,7 @@ const chalk = require('chalk');
|
|
|
5
5
|
const program = new Command();
|
|
6
6
|
|
|
7
7
|
program
|
|
8
|
+
.enablePositionalOptions()
|
|
8
9
|
.name('millas')
|
|
9
10
|
.description(chalk.cyan('⚡ Millas — A modern batteries-included Node.js framework'))
|
|
10
11
|
.version('0.2.12-beta-1');
|
|
@@ -18,6 +19,8 @@ require('./commands/route')(program);
|
|
|
18
19
|
require('./commands/queue')(program);
|
|
19
20
|
require('./commands/createsuperuser')(program);
|
|
20
21
|
require('./commands/lang')(program);
|
|
22
|
+
require('./commands/key')(program);
|
|
23
|
+
require('./commands/call')(program);
|
|
21
24
|
|
|
22
25
|
// Unknown command handler
|
|
23
26
|
program.on('command:*', ([cmd]) => {
|
|
@@ -0,0 +1,190 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
const chalk = require('chalk');
|
|
4
|
+
const path = require('path');
|
|
5
|
+
const fs = require('fs');
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* `millas call <signature> [args] [options]`
|
|
9
|
+
*
|
|
10
|
+
* Discovers all commands in app/commands/, then either:
|
|
11
|
+
* - Runs the matched command directly, OR
|
|
12
|
+
* - Lists all available commands when no signature is given
|
|
13
|
+
*
|
|
14
|
+
* Each command in app/commands/ must extend Command and have a static `signature`.
|
|
15
|
+
*
|
|
16
|
+
* ── Examples ─────────────────────────────────────────────────────────────────
|
|
17
|
+
*
|
|
18
|
+
* millas call — list all custom commands
|
|
19
|
+
* millas call email:digest — run with defaults
|
|
20
|
+
* millas call email:digest weekly — positional arg
|
|
21
|
+
* millas call email:digest --dry-run — flag
|
|
22
|
+
*/
|
|
23
|
+
module.exports = function (program) {
|
|
24
|
+
const commandsDir = path.resolve(process.cwd(), 'app/commands');
|
|
25
|
+
const CommandLoader = require('../console/CommandLoader');
|
|
26
|
+
|
|
27
|
+
// ── Bootstrap helpers ──────────────────────────────────────────────────────
|
|
28
|
+
async function bootstrapApp() {
|
|
29
|
+
const bootstrapPath = path.join(process.cwd(), 'bootstrap/app.js');
|
|
30
|
+
if (!fs.existsSync(bootstrapPath)) return;
|
|
31
|
+
|
|
32
|
+
require('dotenv').config({ path: path.resolve(process.cwd(), '.env') });
|
|
33
|
+
|
|
34
|
+
const basePath = process.cwd();
|
|
35
|
+
const AppServiceProvider = require(path.join(basePath, 'providers/AppServiceProvider'));
|
|
36
|
+
const AppInitializer = require('../container/AppInitializer');
|
|
37
|
+
|
|
38
|
+
const config = {
|
|
39
|
+
basePath,
|
|
40
|
+
providers: [AppServiceProvider],
|
|
41
|
+
routes: null,
|
|
42
|
+
middleware: [],
|
|
43
|
+
logging: true,
|
|
44
|
+
database: true,
|
|
45
|
+
auth: true,
|
|
46
|
+
cache: true,
|
|
47
|
+
storage: true,
|
|
48
|
+
mail: true,
|
|
49
|
+
queue: true,
|
|
50
|
+
events: true,
|
|
51
|
+
admin: null,
|
|
52
|
+
docs: null,
|
|
53
|
+
adapterMiddleware: [],
|
|
54
|
+
};
|
|
55
|
+
|
|
56
|
+
const initializer = new AppInitializer(config);
|
|
57
|
+
await initializer.bootKernel();
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
async function closeDb() {
|
|
61
|
+
try {
|
|
62
|
+
const DatabaseManager = require('../orm/drivers/DatabaseManager');
|
|
63
|
+
await DatabaseManager.closeAll();
|
|
64
|
+
} catch {}
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
// ── millas list ────────────────────────────────────────────────────────────
|
|
68
|
+
program
|
|
69
|
+
.command('list')
|
|
70
|
+
.description('List all available custom commands')
|
|
71
|
+
.action(() => {
|
|
72
|
+
if (!fs.existsSync(commandsDir)) {
|
|
73
|
+
console.log(chalk.yellow('\n No app/commands/ directory found.\n'));
|
|
74
|
+
console.log(chalk.dim(' Run: millas make:command <Name> to create your first command.\n'));
|
|
75
|
+
return;
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
const loader = new CommandLoader(commandsDir);
|
|
79
|
+
loader.load();
|
|
80
|
+
const sigs = loader.signatures();
|
|
81
|
+
|
|
82
|
+
if (sigs.length === 0) {
|
|
83
|
+
console.log(chalk.yellow('\n No custom commands found in app/commands/.\n'));
|
|
84
|
+
console.log(chalk.dim(' Run: millas make:command <Name> to create one.\n'));
|
|
85
|
+
return;
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
const maxLen = Math.max(...sigs.map(s => s.length));
|
|
89
|
+
|
|
90
|
+
console.log(chalk.bold('\n Available commands:\n'));
|
|
91
|
+
for (const [sig, CommandClass] of loader._commands) {
|
|
92
|
+
const desc = CommandClass.description || '';
|
|
93
|
+
console.log(
|
|
94
|
+
' ' + chalk.cyan(sig.padEnd(maxLen + 2)) +
|
|
95
|
+
chalk.dim(desc)
|
|
96
|
+
);
|
|
97
|
+
}
|
|
98
|
+
console.log();
|
|
99
|
+
});
|
|
100
|
+
|
|
101
|
+
// ── millas call <signature> ────────────────────────────────────────────────
|
|
102
|
+
// Uses Commander's allowUnknownOption + passThroughOptions so we can
|
|
103
|
+
// forward all args/options to the matched command.
|
|
104
|
+
const callCmd = program
|
|
105
|
+
.command('call <signature> [args...]')
|
|
106
|
+
.description('Run a custom command from app/commands/')
|
|
107
|
+
.allowUnknownOption(true)
|
|
108
|
+
.passThroughOptions(true)
|
|
109
|
+
.action((signature, extraArgs, options, cmd) => {
|
|
110
|
+
if (!fs.existsSync(commandsDir)) {
|
|
111
|
+
console.error(chalk.red('\n ✖ No app/commands/ directory found.\n'));
|
|
112
|
+
console.error(chalk.dim(' Run: millas make:command <Name> to create your first command.\n'));
|
|
113
|
+
process.exit(1);
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
const loader = new CommandLoader(commandsDir);
|
|
117
|
+
loader.load();
|
|
118
|
+
|
|
119
|
+
const CommandClass = loader._commands.get(signature);
|
|
120
|
+
|
|
121
|
+
if (!CommandClass) {
|
|
122
|
+
const sigs = loader.signatures();
|
|
123
|
+
console.error(chalk.red(`\n ✖ Unknown command: ${chalk.bold(signature)}\n`));
|
|
124
|
+
if (sigs.length) {
|
|
125
|
+
console.log(chalk.dim(' Available commands:'));
|
|
126
|
+
for (const s of sigs) console.log(chalk.dim(` ${s}`));
|
|
127
|
+
} else {
|
|
128
|
+
console.log(chalk.dim(' No custom commands found. Run: millas make:command <Name>'));
|
|
129
|
+
}
|
|
130
|
+
console.log();
|
|
131
|
+
process.exit(1);
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
// Re-parse args + options against the command's own definition
|
|
135
|
+
const { Command: CommanderCmd } = require('commander');
|
|
136
|
+
const sub = new CommanderCmd(signature);
|
|
137
|
+
|
|
138
|
+
const argsDef = CommandClass.args || [];
|
|
139
|
+
const optsDef = CommandClass.options || [];
|
|
140
|
+
|
|
141
|
+
for (const a of argsDef) {
|
|
142
|
+
sub.argument(
|
|
143
|
+
a.default !== undefined ? `[${a.name}]` : `<${a.name}>`,
|
|
144
|
+
a.description || '',
|
|
145
|
+
a.default
|
|
146
|
+
);
|
|
147
|
+
}
|
|
148
|
+
for (const o of optsDef) {
|
|
149
|
+
if (o.default !== undefined) {
|
|
150
|
+
sub.option(o.flag, o.description || '', o.default);
|
|
151
|
+
} else {
|
|
152
|
+
sub.option(o.flag, o.description || '');
|
|
153
|
+
}
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
// Combine extraArgs back with the raw unknown options Commander captured
|
|
157
|
+
const rawArgs = [...(extraArgs || []), ...(cmd.args || [])];
|
|
158
|
+
|
|
159
|
+
// Re-deduplicate (Commander may put some into cmd.args already)
|
|
160
|
+
const allRaw = [...new Set([...extraArgs, ...rawArgs])];
|
|
161
|
+
|
|
162
|
+
sub.parse([process.execPath, signature, ...allRaw]);
|
|
163
|
+
|
|
164
|
+
const parsedOpts = sub.opts();
|
|
165
|
+
const parsedPosArgs = sub.args;
|
|
166
|
+
|
|
167
|
+
const argMap = {};
|
|
168
|
+
for (let i = 0; i < argsDef.length; i++) {
|
|
169
|
+
argMap[argsDef[i].name] = parsedPosArgs[i] !== undefined
|
|
170
|
+
? parsedPosArgs[i]
|
|
171
|
+
: argsDef[i].default;
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
const instance = new CommandClass();
|
|
175
|
+
instance._hydrate(argMap, parsedOpts);
|
|
176
|
+
|
|
177
|
+
// Prevent HTTP server from starting during CLI commands
|
|
178
|
+
process.env.MILLAS_CLI_MODE = '1';
|
|
179
|
+
|
|
180
|
+
Promise.resolve()
|
|
181
|
+
.then(() => bootstrapApp())
|
|
182
|
+
.then(() => instance.handle())
|
|
183
|
+
.catch(err => {
|
|
184
|
+
console.error(chalk.red(`\n ✖ ${signature} failed: ${err.message}\n`));
|
|
185
|
+
if (process.env.DEBUG) console.error(err.stack);
|
|
186
|
+
process.exit(1);
|
|
187
|
+
})
|
|
188
|
+
.finally(() => closeDb());
|
|
189
|
+
});
|
|
190
|
+
};
|
|
@@ -4,6 +4,7 @@ const chalk = require('chalk');
|
|
|
4
4
|
const path = require('path');
|
|
5
5
|
const fs = require('fs-extra');
|
|
6
6
|
const readline = require('readline');
|
|
7
|
+
const Hasher = require('../auth/Hasher');
|
|
7
8
|
|
|
8
9
|
const Log = require("../logger/internal")
|
|
9
10
|
|
|
@@ -65,8 +66,7 @@ module.exports = function (program) {
|
|
|
65
66
|
|
|
66
67
|
// ── Create via Auth.register path but with staff flags ───
|
|
67
68
|
// Hash manually so we can pass the flags in the same create() call.
|
|
68
|
-
const
|
|
69
|
-
const hash = await Hasher.make(plainPassword);
|
|
69
|
+
const hash = await Hasher.make(plainPassword);
|
|
70
70
|
|
|
71
71
|
await User.create({
|
|
72
72
|
email,
|
|
@@ -109,8 +109,7 @@ module.exports = function (program) {
|
|
|
109
109
|
if (plain !== confirm) throw new Error('Passwords do not match.');
|
|
110
110
|
validatePassword(plain);
|
|
111
111
|
|
|
112
|
-
const
|
|
113
|
-
const hash = await Hasher.make(plain);
|
|
112
|
+
const hash = await Hasher.make(plain);
|
|
114
113
|
|
|
115
114
|
await User.where('id', user.id).update({
|
|
116
115
|
password: hash,
|
|
@@ -0,0 +1,97 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
const chalk = require('chalk');
|
|
4
|
+
const fs = require('fs');
|
|
5
|
+
const path = require('path');
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* `millas key:generate`
|
|
9
|
+
*
|
|
10
|
+
* Generates a cryptographically random APP_KEY and writes it into the
|
|
11
|
+
* project's .env file — exactly like Laravel's `php artisan key:generate`.
|
|
12
|
+
*
|
|
13
|
+
* ── Behaviour ────────────────────────────────────────────────────────────────
|
|
14
|
+
*
|
|
15
|
+
* - Reads the .env file in the current working directory
|
|
16
|
+
* - Replaces (or appends) the APP_KEY= line with the new key
|
|
17
|
+
* - Prints the key to stdout
|
|
18
|
+
* - Use --show to print the key without writing to .env
|
|
19
|
+
* - Use --force to overwrite an existing non-empty APP_KEY without prompting
|
|
20
|
+
*
|
|
21
|
+
* ── Examples ─────────────────────────────────────────────────────────────────
|
|
22
|
+
*
|
|
23
|
+
* millas key:generate — generate and write to .env
|
|
24
|
+
* millas key:generate --show — print only, don't write
|
|
25
|
+
* millas key:generate --force — overwrite existing key without prompt
|
|
26
|
+
* millas key:generate --cipher AES-128-CBC
|
|
27
|
+
*/
|
|
28
|
+
module.exports = function (program) {
|
|
29
|
+
program
|
|
30
|
+
.command('key:generate')
|
|
31
|
+
.description('Generate a new application key and write it to .env')
|
|
32
|
+
.option('--show', 'Print the key without writing to .env')
|
|
33
|
+
.option('--force', 'Overwrite existing APP_KEY without confirmation')
|
|
34
|
+
.option('--cipher <cipher>', 'Cipher to use (default: AES-256-CBC)', 'AES-256-CBC')
|
|
35
|
+
.action(async (options) => {
|
|
36
|
+
const { Encrypter } = require('../encryption/Encrypter');
|
|
37
|
+
|
|
38
|
+
// Generate the key
|
|
39
|
+
let key;
|
|
40
|
+
try {
|
|
41
|
+
key = Encrypter.generateKey(options.cipher);
|
|
42
|
+
} catch (err) {
|
|
43
|
+
process.stderr.write(chalk.red(`\n ✖ ${err.message}\n\n`));
|
|
44
|
+
process.exit(1);
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
// --show: just print, don't touch .env
|
|
48
|
+
if (options.show) {
|
|
49
|
+
console.log('\n ' + chalk.cyan(key) + '\n');
|
|
50
|
+
return;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
const envPath = path.resolve(process.cwd(), '.env');
|
|
54
|
+
|
|
55
|
+
if (!fs.existsSync(envPath)) {
|
|
56
|
+
process.stderr.write(chalk.red('\n ✖ .env file not found.\n'));
|
|
57
|
+
process.stderr.write(chalk.dim(' Run: millas new <project> or create a .env file first.\n\n'));
|
|
58
|
+
process.exit(1);
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
let envContent = fs.readFileSync(envPath, 'utf8');
|
|
62
|
+
|
|
63
|
+
// Check if APP_KEY already has a value
|
|
64
|
+
const existing = envContent.match(/^APP_KEY=(.+)$/m);
|
|
65
|
+
const hasValue = existing && existing[1] && existing[1].trim() !== '';
|
|
66
|
+
|
|
67
|
+
if (hasValue && !options.force) {
|
|
68
|
+
// Prompt for confirmation
|
|
69
|
+
const readline = require('readline');
|
|
70
|
+
const rl = readline.createInterface({ input: process.stdin, output: process.stdout });
|
|
71
|
+
const answer = await new Promise(resolve => {
|
|
72
|
+
rl.question(
|
|
73
|
+
chalk.yellow('\n ⚠ APP_KEY already set. Overwrite? (y/N) '),
|
|
74
|
+
ans => { rl.close(); resolve(ans); }
|
|
75
|
+
);
|
|
76
|
+
});
|
|
77
|
+
if ((answer || '').trim().toLowerCase() !== 'y') {
|
|
78
|
+
console.log(chalk.dim('\n Key not changed.\n'));
|
|
79
|
+
return;
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
// Write the key into .env
|
|
84
|
+
if (/^APP_KEY=/m.test(envContent)) {
|
|
85
|
+
// Replace existing line
|
|
86
|
+
envContent = envContent.replace(/^APP_KEY=.*$/m, `APP_KEY=${key}`);
|
|
87
|
+
} else {
|
|
88
|
+
// Append if APP_KEY line is missing entirely
|
|
89
|
+
envContent += `\nAPP_KEY=${key}\n`;
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
fs.writeFileSync(envPath, envContent, 'utf8');
|
|
93
|
+
|
|
94
|
+
console.log(chalk.green('\n ✔ Application key set.\n'));
|
|
95
|
+
console.log(' ' + chalk.dim('APP_KEY=') + chalk.cyan(key) + '\n');
|
|
96
|
+
});
|
|
97
|
+
};
|
package/src/commands/make.js
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
'use strict';
|
|
2
2
|
|
|
3
3
|
const chalk = require('chalk');
|
|
4
|
-
const { makeController, makeModel, makeMiddleware, makeService, makeJob, makeMigration } = require('../scaffold/maker');
|
|
4
|
+
const { makeController, makeModel, makeMiddleware, makeService, makeJob, makeMigration, makeShape, makeCommand } = require('../scaffold/maker');
|
|
5
5
|
|
|
6
6
|
module.exports = function (program) {
|
|
7
7
|
|
|
@@ -48,6 +48,20 @@ module.exports = function (program) {
|
|
|
48
48
|
.action(async (name) => {
|
|
49
49
|
await run('Migration', () => makeMigration(name));
|
|
50
50
|
});
|
|
51
|
+
|
|
52
|
+
program
|
|
53
|
+
.command('make:shape <n>')
|
|
54
|
+
.description('Generate a shape file with Create/Update contracts (app/shapes/)')
|
|
55
|
+
.action(async (name) => {
|
|
56
|
+
await run('Shape', () => makeShape(name));
|
|
57
|
+
});
|
|
58
|
+
program
|
|
59
|
+
.command('make:command <n>')
|
|
60
|
+
.description('Generate a new custom console command in app/commands/')
|
|
61
|
+
.action(async (name) => {
|
|
62
|
+
await run('Command', () => makeCommand(name));
|
|
63
|
+
});
|
|
64
|
+
|
|
51
65
|
};
|
|
52
66
|
|
|
53
67
|
async function run(type, fn) {
|
|
@@ -58,4 +72,4 @@ async function run(type, fn) {
|
|
|
58
72
|
console.error(chalk.red(`\n ✖ Failed to create ${type}: ${err.message}\n`));
|
|
59
73
|
process.exit(1);
|
|
60
74
|
}
|
|
61
|
-
}
|
|
75
|
+
}
|
package/src/commands/new.js
CHANGED
|
@@ -36,6 +36,21 @@ module.exports = function (program) {
|
|
|
36
36
|
installSpinner.succeed(chalk.green(' Dependencies installed!'));
|
|
37
37
|
}
|
|
38
38
|
|
|
39
|
+
// Auto-generate APP_KEY into the new project's .env
|
|
40
|
+
try {
|
|
41
|
+
const { Encrypter } = require('../encryption/Encrypter');
|
|
42
|
+
const envPath = path.join(targetDir, '.env');
|
|
43
|
+
const key = Encrypter.generateKey('AES-256-CBC');
|
|
44
|
+
if (fs.existsSync(envPath)) {
|
|
45
|
+
let envContent = fs.readFileSync(envPath, 'utf8');
|
|
46
|
+
envContent = /^APP_KEY=/m.test(envContent)
|
|
47
|
+
? envContent.replace(/^APP_KEY=.*$/m, `APP_KEY=${key}`)
|
|
48
|
+
: envContent + `\nAPP_KEY=${key}\n`;
|
|
49
|
+
fs.writeFileSync(envPath, envContent, 'utf8');
|
|
50
|
+
console.log(chalk.green(' ✔ Application key generated.'));
|
|
51
|
+
}
|
|
52
|
+
} catch { /* non-fatal — developer can run millas key:generate */ }
|
|
53
|
+
|
|
39
54
|
console.log();
|
|
40
55
|
console.log(chalk.bold(' Next steps:'));
|
|
41
56
|
console.log(chalk.cyan(` cd ${projectName}`));
|
|
@@ -47,4 +62,4 @@ module.exports = function (program) {
|
|
|
47
62
|
process.exit(1);
|
|
48
63
|
}
|
|
49
64
|
});
|
|
50
|
-
};
|
|
65
|
+
};
|